summaryrefslogtreecommitdiffstats
path: root/plugins/odrs
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/odrs')
-rw-r--r--plugins/odrs/gs-plugin-odrs.c1214
-rw-r--r--plugins/odrs/meson.build27
-rw-r--r--plugins/odrs/org.gnome.Software.Plugin.Odrs.metainfo.xml.in12
3 files changed, 1253 insertions, 0 deletions
diff --git a/plugins/odrs/gs-plugin-odrs.c b/plugins/odrs/gs-plugin-odrs.c
new file mode 100644
index 0000000..8c3e9a4
--- /dev/null
+++ b/plugins/odrs/gs-plugin-odrs.c
@@ -0,0 +1,1214 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2016-2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include <config.h>
+
+#include <glib/gi18n.h>
+#include <gnome-software.h>
+#include <json-glib/json-glib.h>
+#include <string.h>
+#include <math.h>
+
+/*
+ * SECTION:
+ * Provides review data from the Open Desktop Ratings Serice.
+ */
+
+#if !GLIB_CHECK_VERSION(2, 62, 0)
+typedef struct
+{
+ guint8 *data;
+ guint len;
+ guint alloc;
+ guint elt_size;
+ guint zero_terminated : 1;
+ guint clear : 1;
+ gatomicrefcount ref_count;
+ GDestroyNotify clear_func;
+} GRealArray;
+
+gboolean
+g_array_binary_search (GArray *array,
+ gconstpointer target,
+ GCompareFunc compare_func,
+ guint *out_match_index)
+{
+ gboolean result = FALSE;
+ GRealArray *_array = (GRealArray *) array;
+ guint left, middle, right;
+ gint val;
+
+ g_return_val_if_fail (_array != NULL, FALSE);
+ g_return_val_if_fail (compare_func != NULL, FALSE);
+
+ if (G_LIKELY(_array->len))
+ {
+ left = 0;
+ right = _array->len - 1;
+
+ while (left <= right)
+ {
+ middle = left + (right - left) / 2;
+
+ val = compare_func (_array->data + (_array->elt_size * middle), target);
+ if (val == 0)
+ {
+ result = TRUE;
+ break;
+ }
+ else if (val < 0)
+ left = middle + 1;
+ else if (/* val > 0 && */ middle > 0)
+ right = middle - 1;
+ else
+ break; /* element not found */
+ }
+ }
+
+ if (result && out_match_index != NULL)
+ *out_match_index = middle;
+
+ return result;
+}
+#endif /* glib < 2.62.0 */
+
+#define ODRS_REVIEW_CACHE_AGE_MAX 237000 /* 1 week */
+#define ODRS_REVIEW_NUMBER_RESULTS_MAX 20
+
+/* Element in priv->ratings, all allocated in one big block and sorted
+ * alphabetically to reduce the number of allocations and fragmentation. */
+typedef struct {
+ gchar *app_id; /* (owned) */
+ guint32 n_star_ratings[6];
+} GsOdrsRating;
+
+static int
+rating_compare (const GsOdrsRating *a, const GsOdrsRating *b)
+{
+ return g_strcmp0 (a->app_id, b->app_id);
+}
+
+static void
+rating_clear (GsOdrsRating *rating)
+{
+ g_free (rating->app_id);
+}
+
+struct GsPluginData {
+ GSettings *settings;
+ gchar *distro;
+ gchar *user_hash;
+ gchar *review_server;
+ GArray *ratings; /* (element-type GsOdrsRating) (mutex ratings_mutex) (owned) (nullable) */
+ GMutex ratings_mutex;
+ GsApp *cached_origin;
+};
+
+void
+gs_plugin_initialize (GsPlugin *plugin)
+{
+ GsPluginData *priv = gs_plugin_alloc_data (plugin, sizeof(GsPluginData));
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsOsRelease) os_release = NULL;
+
+ g_mutex_init (&priv->ratings_mutex);
+ priv->settings = g_settings_new ("org.gnome.software");
+ priv->review_server = g_settings_get_string (priv->settings,
+ "review-server");
+ priv->ratings = NULL; /* until first refreshed */
+
+ /* get the machine+user ID hash value */
+ priv->user_hash = gs_utils_get_user_hash (&error);
+ if (priv->user_hash == NULL) {
+ g_warning ("Failed to get machine+user hash: %s", error->message);
+ return;
+ }
+
+ /* get the distro name (e.g. 'Fedora') but allow a fallback */
+ os_release = gs_os_release_new (&error);
+ if (os_release != NULL) {
+ priv->distro = g_strdup (gs_os_release_get_name (os_release));
+ if (priv->distro == NULL) {
+ g_warning ("no distro name specified");
+ priv->distro = g_strdup ("Unknown");
+ }
+ } else {
+ g_warning ("failed to get distro name: %s", error->message);
+ priv->distro = g_strdup ("Unknown");
+ }
+
+ /* add source */
+ priv->cached_origin = gs_app_new (gs_plugin_get_name (plugin));
+ gs_app_set_kind (priv->cached_origin, AS_APP_KIND_SOURCE);
+ gs_app_set_origin_hostname (priv->cached_origin, priv->review_server);
+
+ /* add the source to the plugin cache which allows us to match the
+ * unique ID to a GsApp when creating an event */
+ gs_plugin_cache_add (plugin,
+ gs_app_get_unique_id (priv->cached_origin),
+ priv->cached_origin);
+
+ /* need application IDs and version */
+ gs_plugin_add_rule (plugin, GS_PLUGIN_RULE_RUN_AFTER, "appstream");
+ gs_plugin_add_rule (plugin, GS_PLUGIN_RULE_RUN_AFTER, "flatpak");
+
+ /* set name of MetaInfo file */
+ gs_plugin_set_appstream_id (plugin, "org.gnome.Software.Plugin.Odrs");
+}
+
+static gboolean
+gs_plugin_odrs_load_ratings_for_app (JsonObject *json_app, const gchar *app_id, GsOdrsRating *rating_out)
+{
+ guint i;
+ const gchar *names[] = { "star0", "star1", "star2", "star3",
+ "star4", "star5", NULL };
+
+ for (i = 0; names[i] != NULL; i++) {
+ if (!json_object_has_member (json_app, names[i]))
+ return FALSE;
+ rating_out->n_star_ratings[i] = (guint64) json_object_get_int_member (json_app, names[i]);
+ }
+
+ rating_out->app_id = g_strdup (app_id);
+
+ return TRUE;
+}
+
+static gboolean
+gs_plugin_odrs_load_ratings (GsPlugin *plugin, const gchar *fn, GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ JsonNode *json_root;
+ JsonObject *json_item;
+ g_autoptr(JsonParser) json_parser = NULL;
+ const gchar *app_id;
+ JsonNode *json_app_node;
+ JsonObjectIter iter;
+ g_autoptr(GArray) new_ratings = NULL;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ /* parse the data and find the success */
+ json_parser = json_parser_new_immutable ();
+#if JSON_CHECK_VERSION(1, 6, 0)
+ if (!json_parser_load_from_mapped_file (json_parser, fn, error)) {
+#else
+ if (!json_parser_load_from_file (json_parser, fn, error)) {
+#endif
+ gs_utils_error_convert_json_glib (error);
+ return FALSE;
+ }
+ json_root = json_parser_get_root (json_parser);
+ if (json_root == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no ratings root");
+ return FALSE;
+ }
+ if (json_node_get_node_type (json_root) != JSON_NODE_OBJECT) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no ratings array");
+ return FALSE;
+ }
+
+ json_item = json_node_get_object (json_root);
+
+ new_ratings = g_array_sized_new (FALSE, /* don’t zero-terminate */
+ FALSE, /* don’t clear */
+ sizeof (GsOdrsRating),
+ json_object_get_size (json_item));
+ g_array_set_clear_func (new_ratings, (GDestroyNotify) rating_clear);
+
+ /* parse each app */
+ json_object_iter_init (&iter, json_item);
+ while (json_object_iter_next (&iter, &app_id, &json_app_node)) {
+ GsOdrsRating rating;
+ JsonObject *json_app;
+
+ if (!JSON_NODE_HOLDS_OBJECT (json_app_node))
+ continue;
+ json_app = json_node_get_object (json_app_node);
+
+ if (gs_plugin_odrs_load_ratings_for_app (json_app, app_id, &rating))
+ g_array_append_val (new_ratings, rating);
+ }
+
+ /* Allow for binary searches later. */
+ g_array_sort (new_ratings, (GCompareFunc) rating_compare);
+
+ /* Update the shared state */
+ locker = g_mutex_locker_new (&priv->ratings_mutex);
+ g_clear_pointer (&priv->ratings, g_array_unref);
+ priv->ratings = g_steal_pointer (&new_ratings);
+
+ return TRUE;
+}
+
+gboolean
+gs_plugin_refresh (GsPlugin *plugin,
+ guint cache_age,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_autofree gchar *cache_filename = NULL;
+ g_autofree gchar *uri = NULL;
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GsApp) app_dl = gs_app_new (gs_plugin_get_name (plugin));
+
+ /* check cache age */
+ cache_filename = gs_utils_get_cache_filename ("odrs",
+ "ratings.json",
+ GS_UTILS_CACHE_FLAG_WRITEABLE,
+ error);
+ if (cache_filename == NULL)
+ return FALSE;
+ if (cache_age > 0) {
+ guint tmp;
+ g_autoptr(GFile) file = NULL;
+ file = g_file_new_for_path (cache_filename);
+ tmp = gs_utils_get_file_age (file);
+ if (tmp < cache_age) {
+ g_debug ("%s is only %u seconds old, so ignoring refresh",
+ cache_filename, tmp);
+ return gs_plugin_odrs_load_ratings (plugin, cache_filename, error);
+ }
+ }
+
+ /* download the complete file */
+ uri = g_strdup_printf ("%s/ratings", priv->review_server);
+ g_debug ("Updating ODRS cache from %s to %s", uri, cache_filename);
+ gs_app_set_summary_missing (app_dl,
+ /* TRANSLATORS: status text when downloading */
+ _("Downloading application ratings…"));
+ if (!gs_plugin_download_file (plugin, app_dl, uri, cache_filename, cancellable, &error_local)) {
+ g_autoptr(GsPluginEvent) event = gs_plugin_event_new ();
+
+ gs_plugin_event_set_error (event, error_local);
+ gs_plugin_event_set_action (event, GS_PLUGIN_ACTION_DOWNLOAD);
+ gs_plugin_event_set_origin (event, priv->cached_origin);
+ if (gs_plugin_has_flags (plugin, GS_PLUGIN_FLAGS_INTERACTIVE))
+ gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
+ else
+ gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING);
+ gs_plugin_report_event (plugin, event);
+
+ /* don't fail updates if the ratings server is unavailable */
+ return TRUE;
+ }
+ return gs_plugin_odrs_load_ratings (plugin, cache_filename, error);
+}
+
+void
+gs_plugin_destroy (GsPlugin *plugin)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_free (priv->user_hash);
+ g_free (priv->distro);
+ g_free (priv->review_server);
+ g_clear_pointer (&priv->ratings, g_array_unref);
+ g_object_unref (priv->settings);
+ g_object_unref (priv->cached_origin);
+ g_mutex_clear (&priv->ratings_mutex);
+}
+
+static AsReview *
+gs_plugin_odrs_parse_review_object (GsPlugin *plugin, JsonObject *item)
+{
+ AsReview *rev = as_review_new ();
+
+ /* date */
+ if (json_object_has_member (item, "date_created")) {
+ gint64 timestamp;
+ g_autoptr(GDateTime) dt = NULL;
+ timestamp = json_object_get_int_member (item, "date_created");
+ dt = g_date_time_new_from_unix_utc (timestamp);
+ as_review_set_date (rev, dt);
+ }
+
+ /* assemble review */
+ if (json_object_has_member (item, "rating"))
+ as_review_set_rating (rev, (gint) json_object_get_int_member (item, "rating"));
+ if (json_object_has_member (item, "score")) {
+ as_review_set_priority (rev, (gint) json_object_get_int_member (item, "score"));
+ } else if (json_object_has_member (item, "karma_up") &&
+ json_object_has_member (item, "karma_down")) {
+ gdouble ku = (gdouble) json_object_get_int_member (item, "karma_up");
+ gdouble kd = (gdouble) json_object_get_int_member (item, "karma_down");
+ gdouble wilson = 0.f;
+
+ /* from http://www.evanmiller.org/how-not-to-sort-by-average-rating.html */
+ if (ku > 0 || kd > 0) {
+ wilson = ((ku + 1.9208) / (ku + kd) -
+ 1.96 * sqrt ((ku * kd) / (ku + kd) + 0.9604) /
+ (ku + kd)) / (1 + 3.8416 / (ku + kd));
+ wilson *= 100.f;
+ }
+ as_review_set_priority (rev, (gint) wilson);
+ }
+ if (json_object_has_member (item, "user_hash"))
+ as_review_set_reviewer_id (rev, json_object_get_string_member (item, "user_hash"));
+ if (json_object_has_member (item, "user_display"))
+ as_review_set_reviewer_name (rev, json_object_get_string_member (item, "user_display"));
+ if (json_object_has_member (item, "summary"))
+ as_review_set_summary (rev, json_object_get_string_member (item, "summary"));
+ if (json_object_has_member (item, "description"))
+ as_review_set_description (rev, json_object_get_string_member (item, "description"));
+ if (json_object_has_member (item, "version"))
+ as_review_set_version (rev, json_object_get_string_member (item, "version"));
+
+ /* add extra metadata for the plugin */
+ if (json_object_has_member (item, "user_skey")) {
+ as_review_add_metadata (rev, "user_skey",
+ json_object_get_string_member (item, "user_skey"));
+ }
+ if (json_object_has_member (item, "app_id")) {
+ as_review_add_metadata (rev, "app_id",
+ json_object_get_string_member (item, "app_id"));
+ }
+ if (json_object_has_member (item, "review_id")) {
+ g_autofree gchar *review_id = NULL;
+ review_id = g_strdup_printf ("%" G_GINT64_FORMAT,
+ json_object_get_int_member (item, "review_id"));
+ as_review_set_id (rev, review_id);
+ }
+
+ /* don't allow multiple votes */
+ if (json_object_has_member (item, "vote_id"))
+ as_review_add_flags (rev, AS_REVIEW_FLAG_VOTED);
+
+ return rev;
+}
+
+static GPtrArray *
+gs_plugin_odrs_parse_reviews (GsPlugin *plugin,
+ const gchar *data,
+ gssize data_len,
+ GError **error)
+{
+ JsonArray *json_reviews;
+ JsonNode *json_root;
+ guint i;
+ g_autoptr(JsonParser) json_parser = NULL;
+ g_autoptr(GHashTable) reviewer_ids = NULL;
+ g_autoptr(GPtrArray) reviews = NULL;
+
+ /* nothing */
+ if (data == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "server returned no data");
+ return NULL;
+ }
+
+ /* parse the data and find the array or ratings */
+ json_parser = json_parser_new_immutable ();
+ if (!json_parser_load_from_data (json_parser, data, data_len, error)) {
+ gs_utils_error_convert_json_glib (error);
+ return NULL;
+ }
+ json_root = json_parser_get_root (json_parser);
+ if (json_root == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no root");
+ return NULL;
+ }
+ if (json_node_get_node_type (json_root) != JSON_NODE_ARRAY) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no array");
+ return NULL;
+ }
+
+ /* parse each rating */
+ reviews = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ json_reviews = json_node_get_array (json_root);
+ reviewer_ids = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+ for (i = 0; i < json_array_get_length (json_reviews); i++) {
+ JsonNode *json_review;
+ JsonObject *json_item;
+ const gchar *reviewer_id;
+ g_autoptr(AsReview) review = NULL;
+
+ /* extract the data */
+ json_review = json_array_get_element (json_reviews, i);
+ if (json_node_get_node_type (json_review) != JSON_NODE_OBJECT) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no object type");
+ return NULL;
+ }
+ json_item = json_node_get_object (json_review);
+ if (json_item == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no object");
+ return NULL;
+ }
+
+ /* create review */
+ review = gs_plugin_odrs_parse_review_object (plugin,
+ json_item);
+
+ reviewer_id = as_review_get_reviewer_id (review);
+ if (reviewer_id == NULL)
+ continue;
+
+ /* dedupe each on the user_hash */
+ if (g_hash_table_lookup (reviewer_ids, reviewer_id) != NULL) {
+ g_debug ("duplicate review %s, skipping", reviewer_id);
+ continue;
+ }
+ g_hash_table_add (reviewer_ids, g_strdup (reviewer_id));
+ g_ptr_array_add (reviews, g_object_ref (review));
+ }
+ return g_steal_pointer (&reviews);
+}
+
+static gboolean
+gs_plugin_odrs_parse_success (const gchar *data, gssize data_len, GError **error)
+{
+ JsonNode *json_root;
+ JsonObject *json_item;
+ const gchar *msg = NULL;
+ g_autoptr(JsonParser) json_parser = NULL;
+
+ /* nothing */
+ if (data == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "server returned no data");
+ return FALSE;
+ }
+
+ /* parse the data and find the success */
+ json_parser = json_parser_new_immutable ();
+ if (!json_parser_load_from_data (json_parser, data, data_len, error)) {
+ gs_utils_error_convert_json_glib (error);
+ return FALSE;
+ }
+ json_root = json_parser_get_root (json_parser);
+ if (json_root == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no error root");
+ return FALSE;
+ }
+ if (json_node_get_node_type (json_root) != JSON_NODE_OBJECT) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no error object");
+ return FALSE;
+ }
+ json_item = json_node_get_object (json_root);
+ if (json_item == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ "no error object");
+ return FALSE;
+ }
+
+ /* failed? */
+ if (json_object_has_member (json_item, "msg"))
+ msg = json_object_get_string_member (json_item, "msg");
+ if (!json_object_get_boolean_member (json_item, "success")) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ msg != NULL ? msg : "unknown failure");
+ return FALSE;
+ }
+
+ /* just for the console */
+ if (msg != NULL)
+ g_debug ("success: %s", msg);
+ return TRUE;
+}
+
+static gboolean
+gs_plugin_odrs_json_post (SoupSession *session,
+ const gchar *uri,
+ const gchar *data,
+ GError **error)
+{
+ guint status_code;
+ g_autoptr(SoupMessage) msg = NULL;
+
+ /* create the GET data */
+ g_debug ("Sending ODRS request to %s: %s", uri, data);
+ msg = soup_message_new (SOUP_METHOD_POST, uri);
+ soup_message_set_request (msg, "application/json; charset=utf-8",
+ SOUP_MEMORY_COPY, data, strlen (data));
+
+ /* set sync request */
+ status_code = soup_session_send_message (session, msg);
+ g_debug ("ODRS server returned status %u: %s", status_code, msg->response_body->data);
+ if (status_code != SOUP_STATUS_OK) {
+ g_warning ("Failed to set rating on ODRS: %s",
+ soup_status_get_phrase (status_code));
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "Failed to submit review to ODRS: %s", soup_status_get_phrase (status_code));
+ return FALSE;
+ }
+
+ /* process returned JSON */
+ return gs_plugin_odrs_parse_success (msg->response_body->data,
+ msg->response_body->length,
+ error);
+}
+
+static GPtrArray *
+_gs_app_get_reviewable_ids (GsApp *app)
+{
+ GPtrArray *ids = g_ptr_array_new_with_free_func (g_free);
+ GPtrArray *provides = gs_app_get_provides (app);
+
+ /* add the main component id */
+ g_ptr_array_add (ids, g_strdup (gs_app_get_id (app)));
+
+ /* add any ID provides */
+ for (guint i = 0; i < provides->len; i++) {
+ AsProvide *provide = g_ptr_array_index (provides, i);
+ if (as_provide_get_kind (provide) == AS_PROVIDE_KIND_ID &&
+ as_provide_get_value (provide) != NULL) {
+ g_ptr_array_add (ids, g_strdup (as_provide_get_value (provide)));
+ }
+ }
+ return ids;
+}
+
+static gboolean
+gs_plugin_odrs_refine_ratings (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ gint rating;
+ guint32 ratings_raw[6] = { 0, 0, 0, 0, 0, 0 };
+ guint cnt = 0;
+ g_autoptr(GArray) review_ratings = NULL;
+ g_autoptr(GPtrArray) reviewable_ids = NULL;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ /* get ratings for each reviewable ID */
+ reviewable_ids = _gs_app_get_reviewable_ids (app);
+
+ locker = g_mutex_locker_new (&priv->ratings_mutex);
+
+ if (priv->ratings == NULL)
+ return TRUE;
+
+ for (guint i = 0; i < reviewable_ids->len; i++) {
+ const gchar *id = g_ptr_array_index (reviewable_ids, i);
+ const GsOdrsRating search_rating = { id, { 0, }};
+ guint found_index;
+ const GsOdrsRating *found_rating;
+
+ if (!g_array_binary_search (priv->ratings, &search_rating,
+ (GCompareFunc) rating_compare, &found_index))
+ continue;
+
+ found_rating = &g_array_index (priv->ratings, GsOdrsRating, found_index);
+
+ /* copy into accumulator array */
+ for (guint j = 0; j < 6; j++)
+ ratings_raw[j] += found_rating->n_star_ratings[j];
+ cnt++;
+ }
+ if (cnt == 0)
+ return TRUE;
+
+ /* Done with priv->ratings now */
+ g_clear_pointer (&locker, g_mutex_locker_free);
+
+ /* merge to accumulator array back to one GArray blob */
+ review_ratings = g_array_sized_new (FALSE, TRUE, sizeof(guint32), 6);
+ for (guint i = 0; i < 6; i++)
+ g_array_append_val (review_ratings, ratings_raw[i]);
+ gs_app_set_review_ratings (app, review_ratings);
+
+ /* find the wilson rating */
+ rating = gs_utils_get_wilson_rating (g_array_index (review_ratings, guint32, 1),
+ g_array_index (review_ratings, guint32, 2),
+ g_array_index (review_ratings, guint32, 3),
+ g_array_index (review_ratings, guint32, 4),
+ g_array_index (review_ratings, guint32, 5));
+ if (rating > 0)
+ gs_app_set_rating (app, rating);
+ return TRUE;
+}
+
+static JsonNode *
+gs_plugin_odrs_get_compat_ids (GsApp *app)
+{
+ GPtrArray *provides = gs_app_get_provides (app);
+ g_autoptr(GHashTable) ids = NULL;
+ g_autoptr(JsonArray) json_array = json_array_new ();
+ g_autoptr(JsonNode) json_node = json_node_new (JSON_NODE_ARRAY);
+
+ ids = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+ for (guint i = 0; i < provides->len; i++) {
+ AsProvide *provide = g_ptr_array_index (provides, i);
+ if (as_provide_get_kind (provide) != AS_PROVIDE_KIND_ID)
+ continue;
+ if (as_provide_get_value (provide) == NULL)
+ continue;
+ if (g_hash_table_lookup (ids, as_provide_get_value (provide)) != NULL)
+ continue;
+ g_hash_table_add (ids, g_strdup (as_provide_get_value (provide)));
+ json_array_add_string_element (json_array, as_provide_get_value (provide));
+ }
+ if (json_array_get_length (json_array) == 0)
+ return NULL;
+ json_node_set_array (json_node, json_array);
+ return g_steal_pointer (&json_node);
+}
+
+static GPtrArray *
+gs_plugin_odrs_fetch_for_app (GsPlugin *plugin, GsApp *app, GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ JsonNode *json_compat_ids;
+ const gchar *version;
+ guint status_code;
+ g_autofree gchar *cachefn_basename = NULL;
+ g_autofree gchar *cachefn = NULL;
+ g_autofree gchar *data = NULL;
+ g_autofree gchar *uri = NULL;
+ g_autoptr(GFile) cachefn_file = NULL;
+ g_autoptr(GPtrArray) reviews = NULL;
+ g_autoptr(JsonBuilder) builder = NULL;
+ g_autoptr(JsonGenerator) json_generator = NULL;
+ g_autoptr(JsonNode) json_root = NULL;
+ g_autoptr(SoupMessage) msg = NULL;
+
+ /* look in the cache */
+ cachefn_basename = g_strdup_printf ("%s.json", gs_app_get_id (app));
+ cachefn = gs_utils_get_cache_filename ("odrs",
+ cachefn_basename,
+ GS_UTILS_CACHE_FLAG_WRITEABLE,
+ error);
+ if (cachefn == NULL)
+ return NULL;
+ cachefn_file = g_file_new_for_path (cachefn);
+ if (gs_utils_get_file_age (cachefn_file) < ODRS_REVIEW_CACHE_AGE_MAX) {
+ g_autoptr(GMappedFile) mapped_file = NULL;
+
+ mapped_file = g_mapped_file_new (cachefn, FALSE, error);
+ if (mapped_file == NULL)
+ return NULL;
+
+ g_debug ("got review data for %s from %s",
+ gs_app_get_id (app), cachefn);
+ return gs_plugin_odrs_parse_reviews (plugin,
+ g_mapped_file_get_contents (mapped_file),
+ g_mapped_file_get_length (mapped_file),
+ error);
+ }
+
+ /* not always available */
+ version = gs_app_get_version (app);
+ if (version == NULL)
+ version = "unknown";
+
+ /* create object with review data */
+ builder = json_builder_new ();
+ json_builder_begin_object (builder);
+ json_builder_set_member_name (builder, "user_hash");
+ json_builder_add_string_value (builder, priv->user_hash);
+ json_builder_set_member_name (builder, "app_id");
+ json_builder_add_string_value (builder, gs_app_get_id (app));
+ json_builder_set_member_name (builder, "locale");
+ json_builder_add_string_value (builder, gs_plugin_get_locale (plugin));
+ json_builder_set_member_name (builder, "distro");
+ json_builder_add_string_value (builder, priv->distro);
+ json_builder_set_member_name (builder, "version");
+ json_builder_add_string_value (builder, version);
+ json_builder_set_member_name (builder, "limit");
+ json_builder_add_int_value (builder, ODRS_REVIEW_NUMBER_RESULTS_MAX);
+ json_compat_ids = gs_plugin_odrs_get_compat_ids (app);
+ if (json_compat_ids != NULL) {
+ json_builder_set_member_name (builder, "compat_ids");
+ json_builder_add_value (builder, json_compat_ids);
+ }
+ json_builder_end_object (builder);
+
+ /* export as a string */
+ json_root = json_builder_get_root (builder);
+ json_generator = json_generator_new ();
+ json_generator_set_pretty (json_generator, TRUE);
+ json_generator_set_root (json_generator, json_root);
+ data = json_generator_to_data (json_generator, NULL);
+ if (data == NULL)
+ return NULL;
+ uri = g_strdup_printf ("%s/fetch", priv->review_server);
+ g_debug ("Updating ODRS cache for %s from %s to %s; request %s", gs_app_get_id (app),
+ uri, cachefn, data);
+ msg = soup_message_new (SOUP_METHOD_POST, uri);
+ soup_message_set_request (msg, "application/json; charset=utf-8",
+ SOUP_MEMORY_COPY, data, strlen (data));
+ status_code = soup_session_send_message (gs_plugin_get_soup_session (plugin), msg);
+ if (status_code != SOUP_STATUS_OK) {
+ if (!gs_plugin_odrs_parse_success (msg->response_body->data,
+ msg->response_body->length,
+ error))
+ return NULL;
+ /* not sure what to do here */
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_DOWNLOAD_FAILED,
+ "status code invalid");
+ gs_utils_error_add_origin_id (error, priv->cached_origin);
+ return NULL;
+ }
+ reviews = gs_plugin_odrs_parse_reviews (plugin,
+ msg->response_body->data,
+ msg->response_body->length,
+ error);
+ if (reviews == NULL)
+ return NULL;
+
+ /* save to the cache */
+ if (!g_file_set_contents (cachefn,
+ msg->response_body->data,
+ msg->response_body->length,
+ error))
+ return NULL;
+
+ /* success */
+ return g_steal_pointer (&reviews);
+}
+
+static gboolean
+gs_plugin_odrs_refine_reviews (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ AsReview *review;
+ g_autoptr(GPtrArray) reviews = NULL;
+
+ /* get from server */
+ reviews = gs_plugin_odrs_fetch_for_app (plugin, app, error);
+ if (reviews == NULL)
+ return FALSE;
+ for (guint i = 0; i < reviews->len; i++) {
+ review = g_ptr_array_index (reviews, i);
+
+ /* save this on the application object so we can use it for
+ * submitting a new review */
+ if (i == 0) {
+ gs_app_set_metadata (app, "ODRS::user_skey",
+ as_review_get_metadata_item (review, "user_skey"));
+ }
+
+ /* ignore invalid reviews */
+ if (as_review_get_rating (review) == 0)
+ continue;
+
+ /* the user_hash matches, so mark this as our own review */
+ if (g_strcmp0 (as_review_get_reviewer_id (review),
+ priv->user_hash) == 0) {
+ as_review_set_flags (review, AS_REVIEW_FLAG_SELF);
+ }
+ gs_app_add_review (app, review);
+ }
+ return TRUE;
+}
+
+static gboolean
+refine_app (GsPlugin *plugin,
+ GsApp *app,
+ GsPluginRefineFlags flags,
+ GCancellable *cancellable,
+ GError **error)
+{
+ /* not valid */
+ if (gs_app_get_kind (app) == AS_APP_KIND_ADDON)
+ return TRUE;
+ if (gs_app_get_id (app) == NULL)
+ return TRUE;
+
+ /* add reviews if possible */
+ if (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS) {
+ if (gs_app_get_reviews(app)->len > 0)
+ return TRUE;
+ if (!gs_plugin_odrs_refine_reviews (plugin, app,
+ cancellable, error))
+ return FALSE;
+ }
+
+ /* add ratings if possible */
+ if (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS ||
+ flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING) {
+ if (gs_app_get_review_ratings(app) != NULL)
+ return TRUE;
+ if (!gs_plugin_odrs_refine_ratings (plugin, app,
+ cancellable, error))
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+gboolean
+gs_plugin_refine (GsPlugin *plugin,
+ GsAppList *list,
+ GsPluginRefineFlags flags,
+ GCancellable *cancellable,
+ GError **error)
+{
+ /* nothing to do here */
+ if ((flags & (GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING)) == 0)
+ return TRUE;
+
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ if (!refine_app (plugin, app, flags, cancellable, error))
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+static gchar *
+gs_plugin_odrs_sanitize_version (const gchar *version)
+{
+ gchar *str;
+ gchar *tmp;
+
+ /* nothing set */
+ if (version == NULL)
+ return g_strdup ("unknown");
+
+ /* remove epoch */
+ str = g_strrstr (version, ":");
+ if (str != NULL)
+ version = str + 1;
+
+ /* remove release */
+ tmp = g_strdup (version);
+ g_strdelimit (tmp, "-", '\0');
+
+ /* remove '+dfsg' suffix */
+ str = g_strstr_len (tmp, -1, "+dfsg");
+ if (str != NULL)
+ *str = '\0';
+
+ return tmp;
+}
+
+static gboolean
+gs_plugin_odrs_invalidate_cache (AsReview *review, GError **error)
+{
+ g_autofree gchar *cachefn_basename = NULL;
+ g_autofree gchar *cachefn = NULL;
+ g_autoptr(GFile) cachefn_file = NULL;
+
+ /* look in the cache */
+ cachefn_basename = g_strdup_printf ("%s.json",
+ as_review_get_metadata_item (review, "app_id"));
+ cachefn = gs_utils_get_cache_filename ("odrs",
+ cachefn_basename,
+ GS_UTILS_CACHE_FLAG_WRITEABLE,
+ error);
+ if (cachefn == NULL)
+ return FALSE;
+ cachefn_file = g_file_new_for_path (cachefn);
+ if (!g_file_query_exists (cachefn_file, NULL))
+ return TRUE;
+ return g_file_delete (cachefn_file, NULL, error);
+}
+
+gboolean
+gs_plugin_review_submit (GsPlugin *plugin,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_autofree gchar *data = NULL;
+ g_autofree gchar *uri = NULL;
+ g_autofree gchar *version = NULL;
+ g_autoptr(JsonBuilder) builder = NULL;
+ g_autoptr(JsonGenerator) json_generator = NULL;
+ g_autoptr(JsonNode) json_root = NULL;
+
+ /* save as we don't re-request the review from the server */
+ as_review_add_flags (review, AS_REVIEW_FLAG_SELF);
+ as_review_set_reviewer_name (review, g_get_real_name ());
+ as_review_add_metadata (review, "app_id", gs_app_get_id (app));
+ as_review_add_metadata (review, "user_skey",
+ gs_app_get_metadata_item (app, "ODRS::user_skey"));
+
+ /* create object with review data */
+ builder = json_builder_new ();
+ json_builder_begin_object (builder);
+ json_builder_set_member_name (builder, "user_hash");
+ json_builder_add_string_value (builder, priv->user_hash);
+ json_builder_set_member_name (builder, "user_skey");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "user_skey"));
+ json_builder_set_member_name (builder, "app_id");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "app_id"));
+ json_builder_set_member_name (builder, "locale");
+ json_builder_add_string_value (builder, gs_plugin_get_locale (plugin));
+ json_builder_set_member_name (builder, "distro");
+ json_builder_add_string_value (builder, priv->distro);
+ json_builder_set_member_name (builder, "version");
+ version = gs_plugin_odrs_sanitize_version (as_review_get_version (review));
+ json_builder_add_string_value (builder, version);
+ json_builder_set_member_name (builder, "user_display");
+ json_builder_add_string_value (builder, as_review_get_reviewer_name (review));
+ json_builder_set_member_name (builder, "summary");
+ json_builder_add_string_value (builder, as_review_get_summary (review));
+ json_builder_set_member_name (builder, "description");
+ json_builder_add_string_value (builder, as_review_get_description (review));
+ json_builder_set_member_name (builder, "rating");
+ json_builder_add_int_value (builder, as_review_get_rating (review));
+ json_builder_end_object (builder);
+
+ /* export as a string */
+ json_root = json_builder_get_root (builder);
+ json_generator = json_generator_new ();
+ json_generator_set_pretty (json_generator, TRUE);
+ json_generator_set_root (json_generator, json_root);
+ data = json_generator_to_data (json_generator, NULL);
+
+ /* clear cache */
+ if (!gs_plugin_odrs_invalidate_cache (review, error))
+ return FALSE;
+
+ /* POST */
+ uri = g_strdup_printf ("%s/submit", priv->review_server);
+ return gs_plugin_odrs_json_post (gs_plugin_get_soup_session (plugin),
+ uri, data, error);
+}
+
+static gboolean
+gs_plugin_odrs_vote (GsPlugin *plugin, AsReview *review,
+ const gchar *uri, GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ const gchar *tmp;
+ g_autofree gchar *data = NULL;
+ g_autoptr(JsonBuilder) builder = NULL;
+ g_autoptr(JsonGenerator) json_generator = NULL;
+ g_autoptr(JsonNode) json_root = NULL;
+
+ /* create object with vote data */
+ builder = json_builder_new ();
+ json_builder_begin_object (builder);
+
+ json_builder_set_member_name (builder, "user_hash");
+ json_builder_add_string_value (builder, priv->user_hash);
+ json_builder_set_member_name (builder, "user_skey");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "user_skey"));
+ json_builder_set_member_name (builder, "app_id");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "app_id"));
+ tmp = as_review_get_id (review);
+ if (tmp != NULL) {
+ gint64 review_id;
+ json_builder_set_member_name (builder, "review_id");
+ review_id = g_ascii_strtoll (tmp, NULL, 10);
+ json_builder_add_int_value (builder, review_id);
+ }
+ json_builder_end_object (builder);
+
+ /* export as a string */
+ json_root = json_builder_get_root (builder);
+ json_generator = json_generator_new ();
+ json_generator_set_pretty (json_generator, TRUE);
+ json_generator_set_root (json_generator, json_root);
+ data = json_generator_to_data (json_generator, NULL);
+ if (data == NULL)
+ return FALSE;
+
+ /* clear cache */
+ if (!gs_plugin_odrs_invalidate_cache (review, error))
+ return FALSE;
+
+ /* send to server */
+ if (!gs_plugin_odrs_json_post (gs_plugin_get_soup_session (plugin),
+ uri, data, error))
+ return FALSE;
+
+ /* mark as voted */
+ as_review_add_flags (review, AS_REVIEW_FLAG_VOTED);
+
+ /* success */
+ return TRUE;
+}
+
+gboolean
+gs_plugin_review_report (GsPlugin *plugin,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/report", priv->review_server);
+ return gs_plugin_odrs_vote (plugin, review, uri, error);
+}
+
+gboolean
+gs_plugin_review_upvote (GsPlugin *plugin,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/upvote", priv->review_server);
+ return gs_plugin_odrs_vote (plugin, review, uri, error);
+}
+
+gboolean
+gs_plugin_review_downvote (GsPlugin *plugin,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/downvote", priv->review_server);
+ return gs_plugin_odrs_vote (plugin, review, uri, error);
+}
+
+gboolean
+gs_plugin_review_dismiss (GsPlugin *plugin,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/dismiss", priv->review_server);
+ return gs_plugin_odrs_vote (plugin, review, uri, error);
+}
+
+gboolean
+gs_plugin_review_remove (GsPlugin *plugin,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/remove", priv->review_server);
+ return gs_plugin_odrs_vote (plugin, review, uri, error);
+}
+
+static GsApp *
+gs_plugin_create_app_dummy (const gchar *id)
+{
+ GsApp *app = gs_app_new (id);
+ g_autoptr(GString) str = NULL;
+ str = g_string_new (id);
+ as_utils_string_replace (str, ".desktop", "");
+ g_string_prepend (str, "No description is available for ");
+ gs_app_set_name (app, GS_APP_QUALITY_LOWEST, "Unknown Application");
+ gs_app_set_summary (app, GS_APP_QUALITY_LOWEST, "Application not found");
+ gs_app_set_description (app, GS_APP_QUALITY_LOWEST, str->str);
+ return app;
+}
+
+gboolean
+gs_plugin_add_unvoted_reviews (GsPlugin *plugin,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginData *priv = gs_plugin_get_data (plugin);
+ guint status_code;
+ guint i;
+ g_autofree gchar *uri = NULL;
+ g_autoptr(GHashTable) hash = NULL;
+ g_autoptr(GPtrArray) reviews = NULL;
+ g_autoptr(SoupMessage) msg = NULL;
+
+ /* create the GET data *with* the machine hash so we can later
+ * review the application ourselves */
+ uri = g_strdup_printf ("%s/moderate/%s/%s",
+ priv->review_server,
+ priv->user_hash,
+ gs_plugin_get_locale (plugin));
+ msg = soup_message_new (SOUP_METHOD_GET, uri);
+ status_code = soup_session_send_message (gs_plugin_get_soup_session (plugin), msg);
+ if (status_code != SOUP_STATUS_OK) {
+ if (!gs_plugin_odrs_parse_success (msg->response_body->data,
+ msg->response_body->length,
+ error))
+ return FALSE;
+ /* not sure what to do here */
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_DOWNLOAD_FAILED,
+ "status code invalid");
+ gs_utils_error_add_origin_id (error, priv->cached_origin);
+ return FALSE;
+ }
+ g_debug ("odrs returned: %s", msg->response_body->data);
+ reviews = gs_plugin_odrs_parse_reviews (plugin,
+ msg->response_body->data,
+ msg->response_body->length,
+ error);
+ if (reviews == NULL)
+ return FALSE;
+
+ /* look at all the reviews; faking application objects */
+ hash = g_hash_table_new_full (g_str_hash, g_str_equal,
+ g_free, (GDestroyNotify) g_object_unref);
+ for (i = 0; i < reviews->len; i++) {
+ GsApp *app;
+ AsReview *review;
+ const gchar *app_id;
+
+ /* same app? */
+ review = g_ptr_array_index (reviews, i);
+ app_id = as_review_get_metadata_item (review, "app_id");
+ app = g_hash_table_lookup (hash, app_id);
+ if (app == NULL) {
+ app = gs_plugin_create_app_dummy (app_id);
+ gs_app_list_add (list, app);
+ g_hash_table_insert (hash, g_strdup (app_id), app);
+ }
+ gs_app_add_review (app, review);
+ }
+
+ return TRUE;
+}
diff --git a/plugins/odrs/meson.build b/plugins/odrs/meson.build
new file mode 100644
index 0000000..254530d
--- /dev/null
+++ b/plugins/odrs/meson.build
@@ -0,0 +1,27 @@
+cargs = ['-DG_LOG_DOMAIN="GsPluginOdrs"']
+
+shared_module(
+ 'gs_plugin_odrs',
+ sources : 'gs-plugin-odrs.c',
+ include_directories : [
+ include_directories('../..'),
+ include_directories('../../lib'),
+ ],
+ install : true,
+ install_dir: plugin_dir,
+ c_args : cargs,
+ dependencies : plugin_libs,
+ link_with : [
+ libgnomesoftware
+ ]
+)
+metainfo = 'org.gnome.Software.Plugin.Odrs.metainfo.xml'
+
+i18n.merge_file(
+ input: metainfo + '.in',
+ output: metainfo,
+ type: 'xml',
+ po_dir: join_paths(meson.source_root(), 'po'),
+ install: true,
+ install_dir: join_paths(get_option('datadir'), 'metainfo')
+)
diff --git a/plugins/odrs/org.gnome.Software.Plugin.Odrs.metainfo.xml.in b/plugins/odrs/org.gnome.Software.Plugin.Odrs.metainfo.xml.in
new file mode 100644
index 0000000..833f69c
--- /dev/null
+++ b/plugins/odrs/org.gnome.Software.Plugin.Odrs.metainfo.xml.in
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Copyright 2013-2016 Richard Hughes <richard@hughsie.com> -->
+<component type="addon">
+ <id>org.gnome.Software.Plugin.Odrs</id>
+ <extends>org.gnome.Software.desktop</extends>
+ <name>Open Desktop Ratings Support</name>
+ <summary>ODRS is a service providing user reviews of applications</summary>
+ <url type="homepage">https://odrs.gnome.org/</url>
+ <metadata_license>CC0-1.0</metadata_license>
+ <project_license>GPL-2.0+</project_license>
+ <update_contact>richard_at_hughsie.com</update_contact>
+</component>