From 6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:57:27 +0200 Subject: Adding upstream version 43.5. Signed-off-by: Daniel Baumann --- lib/gs-odrs-provider.c | 1998 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1998 insertions(+) create mode 100644 lib/gs-odrs-provider.c (limited to 'lib/gs-odrs-provider.c') diff --git a/lib/gs-odrs-provider.c b/lib/gs-odrs-provider.c new file mode 100644 index 0000000..cb9d37d --- /dev/null +++ b/lib/gs-odrs-provider.c @@ -0,0 +1,1998 @@ +/* -*- 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 + * Copyright (C) 2016-2018 Kalev Lember + * Copyright (C) 2021 Endless OS Foundation LLC + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/* + * SECTION:gs-odrs-provider + * @short_description: Provides review data from the Open Desktop Ratings Service. + * + * To test this plugin locally you will probably want to build and run the + * `odrs-web` container, following the instructions in the + * [`odrs-web` repository](https://gitlab.gnome.org/Infrastructure/odrs-web/-/blob/HEAD/app_data/README.md), + * and then get gnome-software to use your local review server by running: + * ``` + * gsettings set org.gnome.software review-server 'http://127.0.0.1:5000/1.0/reviews/api' + * ``` + * + * When you are done with development, run the following command to use the real + * ODRS server again: + * ``` + * gsettings reset org.gnome.software review-server + * ``` + * + * Since: 41 + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +G_DEFINE_QUARK (gs-odrs-provider-error-quark, gs_odrs_provider_error) + +/* Element in self->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 _GsOdrsProvider +{ + GObject parent_instance; + + gchar *distro; /* (not nullable) (owned) */ + gchar *user_hash; /* (not nullable) (owned) */ + gchar *review_server; /* (not nullable) (owned) */ + GArray *ratings; /* (element-type GsOdrsRating) (mutex ratings_mutex) (owned) (nullable) */ + GMutex ratings_mutex; + guint64 max_cache_age_secs; + guint n_results_max; + SoupSession *session; /* (owned) (not nullable) */ +}; + +G_DEFINE_TYPE (GsOdrsProvider, gs_odrs_provider, G_TYPE_OBJECT) + +typedef enum { + PROP_REVIEW_SERVER = 1, + PROP_USER_HASH, + PROP_DISTRO, + PROP_MAX_CACHE_AGE_SECS, + PROP_N_RESULTS_MAX, + PROP_SESSION, +} GsOdrsProviderProperty; + +static GParamSpec *obj_props[PROP_SESSION + 1] = { NULL, }; + +static gboolean +gs_odrs_provider_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_odrs_provider_load_ratings (GsOdrsProvider *self, + const gchar *filename, + GError **error) +{ + 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; + g_autoptr(GError) local_error = NULL; + + /* parse the data and find the success */ + json_parser = json_parser_new_immutable (); + if (!json_parser_load_from_mapped_file (json_parser, filename, &local_error)) { + g_set_error (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "Error parsing ODRS data: %s", local_error->message); + return FALSE; + } + json_root = json_parser_get_root (json_parser); + if (json_root == NULL) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "no ratings root"); + return FALSE; + } + if (json_node_get_node_type (json_root) != JSON_NODE_OBJECT) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "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_odrs_provider_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 (&self->ratings_mutex); + g_clear_pointer (&self->ratings, g_array_unref); + self->ratings = g_steal_pointer (&new_ratings); + + return TRUE; +} + +static AsReview * +gs_odrs_provider_parse_review_object (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")) { + g_autofree gchar *user_display = g_strdup (json_object_get_string_member (item, "user_display")); + if (user_display) + g_strstrip (user_display); + as_review_set_reviewer_name (rev, user_display); + } + if (json_object_has_member (item, "summary")) { + g_autofree gchar *summary = g_strdup (json_object_get_string_member (item, "summary")); + if (summary) + g_strstrip (summary); + as_review_set_summary (rev, summary); + } + if (json_object_has_member (item, "description")) { + g_autofree gchar *description = g_strdup (json_object_get_string_member (item, "description")); + if (description) + g_strstrip (description); + as_review_set_description (rev, 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; +} + +/* json_parser_load*() must have been called on @json_parser before calling + * this function. */ +static GPtrArray * +gs_odrs_provider_parse_reviews (GsOdrsProvider *self, + JsonParser *json_parser, + GError **error) +{ + JsonArray *json_reviews; + JsonNode *json_root; + guint i; + g_autoptr(GHashTable) reviewer_ids = NULL; + g_autoptr(GPtrArray) reviews = NULL; + g_autoptr(GError) local_error = NULL; + + json_root = json_parser_get_root (json_parser); + if (json_root == NULL) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "no root"); + return NULL; + } + if (json_node_get_node_type (json_root) != JSON_NODE_ARRAY) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "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_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "no object type"); + return NULL; + } + json_item = json_node_get_object (json_review); + if (json_item == NULL) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "no object"); + return NULL; + } + + /* create review */ + review = gs_odrs_provider_parse_review_object (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_odrs_provider_parse_success (GInputStream *input_stream, + GError **error) +{ + JsonNode *json_root; + JsonObject *json_item; + const gchar *msg = NULL; + g_autoptr(JsonParser) json_parser = NULL; + g_autoptr(GError) local_error = NULL; + + /* parse the data and find the success + * FIXME: This should probably eventually be refactored and made async */ + json_parser = json_parser_new_immutable (); + if (!json_parser_load_from_stream (json_parser, input_stream, NULL, &local_error)) { + g_set_error (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "Error parsing ODRS data: %s", local_error->message); + return FALSE; + } + json_root = json_parser_get_root (json_parser); + if (json_root == NULL) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "no error root"); + return FALSE; + } + if (json_node_get_node_type (json_root) != JSON_NODE_OBJECT) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "no error object"); + return FALSE; + } + json_item = json_node_get_object (json_root); + if (json_item == NULL) { + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "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_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + msg != NULL ? msg : "unknown failure"); + return FALSE; + } + + /* just for the console */ + if (msg != NULL) + g_debug ("success: %s", msg); + return TRUE; +} + +#if SOUP_CHECK_VERSION(3, 0, 0) +typedef struct { + GInputStream *input_stream; + gssize length; + goffset read_from; +} MessageData; + +static MessageData * +message_data_new (GInputStream *input_stream, + gssize length) +{ + MessageData *md; + + md = g_slice_new0 (MessageData); + md->input_stream = g_object_ref (input_stream); + md->length = length; + + if (G_IS_SEEKABLE (input_stream)) + md->read_from = g_seekable_tell (G_SEEKABLE (input_stream)); + + return md; +} + +static void +message_data_free (gpointer ptr, + GClosure *closure) +{ + MessageData *md = ptr; + + if (md) { + g_object_unref (md->input_stream); + g_slice_free (MessageData, md); + } +} + +static void +g_odrs_provider_message_restarted_cb (SoupMessage *message, + gpointer user_data) +{ + MessageData *md = user_data; + + if (G_IS_SEEKABLE (md->input_stream) && md->read_from != g_seekable_tell (G_SEEKABLE (md->input_stream))) + g_seekable_seek (G_SEEKABLE (md->input_stream), md->read_from, G_SEEK_SET, NULL, NULL); + + soup_message_set_request_body (message, NULL, md->input_stream, md->length); +} + +static void +g_odrs_provider_set_message_request_body (SoupMessage *message, + const gchar *content_type, + gconstpointer data, + gsize length) +{ + MessageData *md; + GInputStream *input_stream; + + g_return_if_fail (SOUP_IS_MESSAGE (message)); + g_return_if_fail (data != NULL); + + input_stream = g_memory_input_stream_new_from_data (g_memdup2 (data, length), length, g_free); + md = message_data_new (input_stream, length); + + g_signal_connect_data (message, "restarted", + G_CALLBACK (g_odrs_provider_message_restarted_cb), md, message_data_free, 0); + + soup_message_set_request_body (message, content_type, input_stream, length); + + g_object_unref (input_stream); +} +#endif + +static gboolean +gs_odrs_provider_json_post (SoupSession *session, + const gchar *uri, + const gchar *data, + GCancellable *cancellable, + GError **error) +{ + guint status_code; + g_autoptr(SoupMessage) msg = NULL; + gconstpointer downloaded_data; + gsize downloaded_data_length; + g_autoptr(GInputStream) input_stream = NULL; +#if SOUP_CHECK_VERSION(3, 0, 0) + g_autoptr(GBytes) bytes = NULL; +#endif + /* create the GET data */ + g_debug ("Sending ODRS request to %s: %s", uri, data); + msg = soup_message_new (SOUP_METHOD_POST, uri); +#if SOUP_CHECK_VERSION(3, 0, 0) + g_odrs_provider_set_message_request_body (msg, "application/json; charset=utf-8", + data, strlen (data)); + bytes = soup_session_send_and_read (session, msg, cancellable, error); + if (bytes == NULL) + return FALSE; + + downloaded_data = g_bytes_get_data (bytes, &downloaded_data_length); + status_code = soup_message_get_status (msg); +#else + 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); + downloaded_data = msg->response_body ? msg->response_body->data : NULL; + downloaded_data_length = msg->response_body ? msg->response_body->length : 0; +#endif + g_debug ("ODRS server returned status %u: %.*s", status_code, (gint) downloaded_data_length, (const gchar *) downloaded_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_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_SERVER_ERROR, + "Failed to submit review to ODRS: %s", soup_status_get_phrase (status_code)); + return FALSE; + } + + /* process returned JSON */ + input_stream = g_memory_input_stream_new_from_data (downloaded_data, downloaded_data_length, NULL); + return gs_odrs_provider_parse_success (input_stream, error); +} + +static GPtrArray * +_gs_app_get_reviewable_ids (GsApp *app) +{ + GPtrArray *ids = g_ptr_array_new_with_free_func (g_free); + GPtrArray *provided = gs_app_get_provided (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 < provided->len; i++) { + GPtrArray *items; + AsProvided *prov = g_ptr_array_index (provided, i); + if (as_provided_get_kind (prov) != AS_PROVIDED_KIND_ID) + continue; + + items = as_provided_get_items (prov); + for (guint j = 0; j < items->len; j++) { + const gchar *value = (const gchar *) g_ptr_array_index (items, j); + if (value == NULL) + continue; + g_ptr_array_add (ids, g_strdup (value)); + } + } + return ids; +} + +static gboolean +gs_odrs_provider_refine_ratings (GsOdrsProvider *self, + GsApp *app, + GCancellable *cancellable, + GError **error) +{ + 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 (&self->ratings_mutex); + + if (!self->ratings) { + g_autofree gchar *cache_filename = NULL; + + g_clear_pointer (&locker, g_mutex_locker_free); + + /* Load from the local cache, if available, when in offline or + when refresh/download disabled on start */ + cache_filename = gs_utils_get_cache_filename ("odrs", + "ratings.json", + GS_UTILS_CACHE_FLAG_WRITEABLE | + GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY, + error); + + if (!cache_filename) + return TRUE; + + if (!gs_odrs_provider_load_ratings (self, cache_filename, NULL)) { + g_autoptr(GFile) cache_file = g_file_new_for_path (cache_filename); + g_debug ("Failed to load cache file ‘%s’, deleting it", cache_filename); + g_file_delete (cache_file, NULL, NULL); + return TRUE; + } + + locker = g_mutex_locker_new (&self->ratings_mutex); + + if (!self->ratings) + 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 = { (gchar *) id, { 0, }}; + guint found_index; + const GsOdrsRating *found_rating; + + if (!g_array_binary_search (self->ratings, &search_rating, + (GCompareFunc) rating_compare, &found_index)) + continue; + + found_rating = &g_array_index (self->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 self->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_odrs_provider_get_compat_ids (GsApp *app) +{ + GPtrArray *provided = gs_app_get_provided (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, NULL, NULL); + for (guint i = 0; i < provided->len; i++) { + GPtrArray *items; + AsProvided *prov = g_ptr_array_index (provided, i); + + if (as_provided_get_kind (prov) != AS_PROVIDED_KIND_ID) + continue; + + items = as_provided_get_items (prov); + for (guint j = 0; j < items->len; j++) { + const gchar *value = g_ptr_array_index (items, j); + if (value == NULL) + continue; + + if (g_hash_table_add (ids, (gpointer) value)) + json_array_add_string_element (json_array, value); + } + } + 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 void open_input_stream_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data); +static void parse_reviews_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data); +static void set_reviews_on_app (GsOdrsProvider *self, + GsApp *app, + GPtrArray *reviews); + +typedef struct { + GsApp *app; /* (not nullable) (owned) */ + gchar *cache_filename; /* (not nullable) (owned) */ + SoupMessage *message; /* (nullable) (owned) */ +} FetchReviewsForAppData; + +static void +fetch_reviews_for_app_data_free (FetchReviewsForAppData *data) +{ + g_clear_object (&data->app); + g_free (data->cache_filename); + g_clear_object (&data->message); + + g_free (data); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC (FetchReviewsForAppData, fetch_reviews_for_app_data_free) + +static void +gs_odrs_provider_fetch_reviews_for_app_async (GsOdrsProvider *self, + GsApp *app, + GCancellable *cancellable, + GAsyncReadyCallback callback, + gpointer user_data) +{ + JsonNode *json_compat_ids; + const gchar *version; + g_autofree gchar *cachefn_basename = NULL; + g_autofree gchar *cachefn = NULL; + g_autofree gchar *request_body = NULL; + g_autofree gchar *uri = NULL; + g_autoptr(GFile) cachefn_file = NULL; + g_autoptr(GPtrArray) reviews = NULL; + g_autoptr(JsonBuilder) builder = NULL; + g_autoptr(JsonParser) json_parser = NULL; + g_autoptr(JsonGenerator) json_generator = NULL; + g_autoptr(JsonNode) json_root = NULL; + g_autoptr(SoupMessage) msg = NULL; +#if SOUP_CHECK_VERSION(3, 0, 0) + g_autoptr(GBytes) bytes = NULL; +#endif + g_autoptr(GTask) task = NULL; + FetchReviewsForAppData *data; + g_autoptr(FetchReviewsForAppData) data_owned = NULL; + g_autoptr(GError) local_error = NULL; + + task = g_task_new (self, cancellable, callback, user_data); + g_task_set_source_tag (task, gs_odrs_provider_fetch_reviews_for_app_async); + + data = data_owned = g_new0 (FetchReviewsForAppData, 1); + data->app = g_object_ref (app); + g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) fetch_reviews_for_app_data_free); + + /* 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 | + GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY, + &local_error); + if (cachefn == NULL) { + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + data->cache_filename = g_strdup (cachefn); + cachefn_file = g_file_new_for_path (cachefn); + if (gs_utils_get_file_age (cachefn_file) < self->max_cache_age_secs) { + g_debug ("got review data for %s from %s", + gs_app_get_id (app), cachefn); + + /* parse the data and find the array of ratings */ + json_parser = json_parser_new_immutable (); + if (!json_parser_load_from_mapped_file (json_parser, cachefn, &local_error)) { + g_task_return_new_error (task, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "Error parsing ODRS data: %s", local_error->message); + return; + } + + reviews = gs_odrs_provider_parse_reviews (self, json_parser, &local_error); + if (reviews == NULL) { + g_task_return_error (task, g_steal_pointer (&local_error)); + } else { + set_reviews_on_app (self, app, reviews); + g_task_return_boolean (task, TRUE); + } + + return; + } + + /* 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, self->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, setlocale (LC_MESSAGES, NULL)); + json_builder_set_member_name (builder, "distro"); + json_builder_add_string_value (builder, self->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, self->n_results_max); + json_compat_ids = gs_odrs_provider_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); + request_body = json_generator_to_data (json_generator, NULL); + + uri = g_strdup_printf ("%s/fetch", self->review_server); + g_debug ("Updating ODRS cache for %s from %s to %s; request %s", gs_app_get_id (app), + uri, cachefn, request_body); + msg = soup_message_new (SOUP_METHOD_POST, uri); + data->message = g_object_ref (msg); + +#if SOUP_CHECK_VERSION(3, 0, 0) + g_odrs_provider_set_message_request_body (msg, "application/json; charset=utf-8", + request_body, strlen (request_body)); + soup_session_send_async (self->session, msg, G_PRIORITY_DEFAULT, + cancellable, open_input_stream_cb, g_steal_pointer (&task)); +#else + soup_message_set_request (msg, "application/json; charset=utf-8", + SOUP_MEMORY_COPY, request_body, strlen (request_body)); + soup_session_send_async (self->session, msg, cancellable, + open_input_stream_cb, g_steal_pointer (&task)); +#endif +} + +static void +open_input_stream_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + SoupSession *soup_session = SOUP_SESSION (source_object); + g_autoptr(GTask) task = g_steal_pointer (&user_data); + FetchReviewsForAppData *data = g_task_get_task_data (task); + GCancellable *cancellable = g_task_get_cancellable (task); + g_autoptr(GInputStream) input_stream = NULL; + guint status_code; + g_autoptr(JsonParser) json_parser = NULL; + g_autoptr(GError) local_error = NULL; + +#if SOUP_CHECK_VERSION(3, 0, 0) + input_stream = soup_session_send_finish (soup_session, result, &local_error); + status_code = soup_message_get_status (data->message); +#else + input_stream = soup_session_send_finish (soup_session, result, &local_error); + status_code = data->message->status_code; +#endif + + if (input_stream == NULL) { + if (!g_network_monitor_get_network_available (g_network_monitor_get_default ())) + g_task_return_new_error (task, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_NO_NETWORK, + "server couldn't be reached"); + else + g_task_return_new_error (task, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "server returned no data"); + return; + } + + if (status_code != SOUP_STATUS_OK) { + if (!gs_odrs_provider_parse_success (input_stream, &local_error)) { + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + /* not sure what to do here */ + g_task_return_new_error (task, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_DOWNLOADING, + "status code invalid"); + return; + } + + /* parse the data and find the array of ratings */ + json_parser = json_parser_new_immutable (); + json_parser_load_from_stream_async (json_parser, input_stream, cancellable, parse_reviews_cb, g_steal_pointer (&task)); +} + +static void +parse_reviews_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + JsonParser *json_parser = JSON_PARSER (source_object); + g_autoptr(GTask) task = g_steal_pointer (&user_data); + GsOdrsProvider *self = g_task_get_source_object (task); + FetchReviewsForAppData *data = g_task_get_task_data (task); + g_autoptr(GPtrArray) reviews = NULL; + g_autoptr(JsonGenerator) cache_generator = NULL; + g_autoptr(GError) local_error = NULL; + + if (!json_parser_load_from_stream_finish (json_parser, result, &local_error)) { + g_task_return_new_error (task, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "Error parsing ODRS data: %s", local_error->message); + return; + } + + reviews = gs_odrs_provider_parse_reviews (self, json_parser, &local_error); + if (reviews == NULL) { + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + /* save to the cache */ + cache_generator = json_generator_new (); + json_generator_set_pretty (cache_generator, FALSE); + json_generator_set_root (cache_generator, json_parser_get_root (json_parser)); + + if (!json_generator_to_file (cache_generator, data->cache_filename, &local_error)) { + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + set_reviews_on_app (self, data->app, reviews); + + /* success */ + g_task_return_boolean (task, TRUE); +} + +static void +set_reviews_on_app (GsOdrsProvider *self, + GsApp *app, + GPtrArray *reviews) +{ + for (guint i = 0; i < reviews->len; i++) { + AsReview *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), + self->user_hash) == 0) { + as_review_set_flags (review, AS_REVIEW_FLAG_SELF); + } + gs_app_add_review (app, review); + } +} + +static gboolean +gs_odrs_provider_fetch_reviews_for_app_finish (GsOdrsProvider *self, + GAsyncResult *result, + GError **error) +{ + return g_task_propagate_boolean (G_TASK (result), error); +} + +static gchar * +gs_odrs_provider_trim_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_odrs_provider_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 | + GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY, + 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); +} + +static gboolean +gs_odrs_provider_vote (GsOdrsProvider *self, + AsReview *review, + const gchar *uri, + GCancellable *cancellable, + GError **error) +{ + 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, self->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_odrs_provider_invalidate_cache (review, error)) + return FALSE; + + /* send to server */ + if (!gs_odrs_provider_json_post (self->session, uri, data, cancellable, error)) + return FALSE; + + /* mark as voted */ + as_review_add_flags (review, AS_REVIEW_FLAG_VOTED); + + /* success */ + return TRUE; +} + +static GsApp * +gs_odrs_provider_create_app_dummy (const gchar *id) +{ + GsApp *app = gs_app_new (id); + g_autoptr(GString) str = NULL; + str = g_string_new (id); + as_gstring_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; +} + +static void +gs_odrs_provider_init (GsOdrsProvider *self) +{ + g_mutex_init (&self->ratings_mutex); +} + +static void +gs_odrs_provider_constructed (GObject *object) +{ + GsOdrsProvider *self = GS_ODRS_PROVIDER (object); + + G_OBJECT_CLASS (gs_odrs_provider_parent_class)->constructed (object); + + /* Check all required properties have been set. */ + g_assert (self->review_server != NULL); + g_assert (self->user_hash != NULL); + g_assert (self->distro != NULL); +} + +static void +gs_odrs_provider_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsOdrsProvider *self = GS_ODRS_PROVIDER (object); + + switch ((GsOdrsProviderProperty) prop_id) { + case PROP_REVIEW_SERVER: + g_value_set_string (value, self->review_server); + break; + case PROP_USER_HASH: + g_value_set_string (value, self->user_hash); + break; + case PROP_DISTRO: + g_value_set_string (value, self->distro); + break; + case PROP_MAX_CACHE_AGE_SECS: + g_value_set_uint64 (value, self->max_cache_age_secs); + break; + case PROP_N_RESULTS_MAX: + g_value_set_uint (value, self->n_results_max); + break; + case PROP_SESSION: + g_value_set_object (value, self->session); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_odrs_provider_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsOdrsProvider *self = GS_ODRS_PROVIDER (object); + + switch ((GsOdrsProviderProperty) prop_id) { + case PROP_REVIEW_SERVER: + /* Construct-only */ + g_assert (self->review_server == NULL); + self->review_server = g_value_dup_string (value); + break; + case PROP_USER_HASH: + /* Construct-only */ + g_assert (self->user_hash == NULL); + self->user_hash = g_value_dup_string (value); + break; + case PROP_DISTRO: + /* Construct-only */ + g_assert (self->distro == NULL); + self->distro = g_value_dup_string (value); + break; + case PROP_MAX_CACHE_AGE_SECS: + /* Construct-only */ + g_assert (self->max_cache_age_secs == 0); + self->max_cache_age_secs = g_value_get_uint64 (value); + break; + case PROP_N_RESULTS_MAX: + /* Construct-only */ + g_assert (self->n_results_max == 0); + self->n_results_max = g_value_get_uint (value); + break; + case PROP_SESSION: + /* Construct-only */ + g_assert (self->session == NULL); + self->session = g_value_dup_object (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_odrs_provider_dispose (GObject *object) +{ + GsOdrsProvider *self = GS_ODRS_PROVIDER (object); + + g_clear_object (&self->session); + + G_OBJECT_CLASS (gs_odrs_provider_parent_class)->dispose (object); +} + +static void +gs_odrs_provider_finalize (GObject *object) +{ + GsOdrsProvider *self = GS_ODRS_PROVIDER (object); + + g_free (self->user_hash); + g_free (self->distro); + g_free (self->review_server); + g_clear_pointer (&self->ratings, g_array_unref); + g_mutex_clear (&self->ratings_mutex); + + G_OBJECT_CLASS (gs_odrs_provider_parent_class)->finalize (object); +} + +static void +gs_odrs_provider_class_init (GsOdrsProviderClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->constructed = gs_odrs_provider_constructed; + object_class->get_property = gs_odrs_provider_get_property; + object_class->set_property = gs_odrs_provider_set_property; + object_class->dispose = gs_odrs_provider_dispose; + object_class->finalize = gs_odrs_provider_finalize; + + /** + * GsOdrsProvider:review-server: (not nullable) + * + * The URI of the ODRS review server to contact. + * + * Since: 41 + */ + obj_props[PROP_REVIEW_SERVER] = + g_param_spec_string ("review-server", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY); + + /** + * GsOdrsProvider:user-hash: (not nullable) + * + * An opaque hash of the user identifier, used to identify the user on + * the server. + * + * Since: 41 + */ + obj_props[PROP_USER_HASH] = + g_param_spec_string ("user-hash", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY); + + /** + * GsOdrsProvider:distro: (not nullable) + * + * A human readable string identifying the current distribution. + * + * Since: 41 + */ + obj_props[PROP_DISTRO] = + g_param_spec_string ("distro", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY); + + /** + * GsOdrsProvider:max-cache-age-secs: + * + * The maximum age of the ODRS cache files, in seconds. Older files will + * be refreshed on demand. + * + * Since: 41 + */ + obj_props[PROP_MAX_CACHE_AGE_SECS] = + g_param_spec_uint64 ("max-cache-age-secs", NULL, NULL, + 0, G_MAXUINT64, 0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY); + + /** + * GsOdrsProvider:n-results-max: + * + * Maximum number of reviews or ratings to download. The default value + * of 0 means no limit is applied. + * + * Since: 41 + */ + obj_props[PROP_N_RESULTS_MAX] = + g_param_spec_uint ("n-results-max", NULL, NULL, + 0, G_MAXUINT, 0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY); + + /** + * GsOdrsProvider:session: (not nullable) + * + * #SoupSession to use for downloading things. + * + * Since: 41 + */ + obj_props[PROP_SESSION] = + g_param_spec_object ("session", NULL, NULL, + SOUP_TYPE_SESSION, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); +} + +/** + * gs_odrs_provider_new: + * @review_server: (not nullable): value for #GsOdrsProvider:review-server + * @user_hash: (not nullable): value for #GsOdrsProvider:user-hash + * @distro: (not nullable): value for #GsOdrsProvider:distro + * @max_cache_age_secs: value for #GsOdrsProvider:max-cache-age-secs + * @n_results_max: value for #GsOdrsProvider:n-results-max + * @session: value for #GsOdrsProvider:session + * + * Create a new #GsOdrsProvider. This does no network activity. + * + * Returns: (transfer full): a new #GsOdrsProvider + * Since: 41 + */ +GsOdrsProvider * +gs_odrs_provider_new (const gchar *review_server, + const gchar *user_hash, + const gchar *distro, + guint64 max_cache_age_secs, + guint n_results_max, + SoupSession *session) +{ + g_return_val_if_fail (review_server != NULL && *review_server != '\0', NULL); + g_return_val_if_fail (user_hash != NULL && *user_hash != '\0', NULL); + g_return_val_if_fail (distro != NULL && *distro != '\0', NULL); + g_return_val_if_fail (SOUP_IS_SESSION (session), NULL); + + return g_object_new (GS_TYPE_ODRS_PROVIDER, + "review-server", review_server, + "user-hash", user_hash, + "distro", distro, + "max-cache-age-secs", max_cache_age_secs, + "n-results-max", n_results_max, + "session", session, + NULL); +} + +static void download_ratings_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data); + +/** + * gs_odrs_provider_refresh_ratings_async: + * @self: a #GsOdrsProvider + * @cache_age_secs: cache age, in seconds, as passed to #GsPluginClass.refresh_metadata_async() + * @progress_callback: (nullable): callback to call with progress information + * @progress_user_data: (nullable) (closure progress_callback): data to pass + * to @progress_callback + * @cancellable: (nullable): a #GCancellable, or %NULL + * @callback: function to call when the asynchronous operation is complete + * @user_data: data to pass to @callback + * + * Refresh the cached ODRS ratings and re-load them asynchronously. + * + * Since: 42 + */ +void +gs_odrs_provider_refresh_ratings_async (GsOdrsProvider *self, + guint64 cache_age_secs, + GsDownloadProgressCallback progress_callback, + gpointer progress_user_data, + GCancellable *cancellable, + GAsyncReadyCallback callback, + gpointer user_data) +{ + g_autofree gchar *cache_filename = NULL; + g_autoptr(GFile) cache_file = NULL; + g_autofree gchar *uri = NULL; + g_autoptr(GError) error_local = NULL; + g_autoptr(GTask) task = NULL; + + task = g_task_new (self, cancellable, callback, user_data); + g_task_set_source_tag (task, gs_odrs_provider_refresh_ratings_async); + + /* check cache age */ + cache_filename = gs_utils_get_cache_filename ("odrs", + "ratings.json", + GS_UTILS_CACHE_FLAG_WRITEABLE | + GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY, + &error_local); + if (cache_filename == NULL) { + g_task_return_error (task, g_steal_pointer (&error_local)); + return; + } + + cache_file = g_file_new_for_path (cache_filename); + g_task_set_task_data (task, g_object_ref (cache_file), g_object_unref); + + if (cache_age_secs > 0) { + guint64 tmp; + + tmp = gs_utils_get_file_age (cache_file); + if (tmp < cache_age_secs) { + g_debug ("%s is only %" G_GUINT64_FORMAT " seconds old, so ignoring refresh", + cache_filename, tmp); + if (!gs_odrs_provider_load_ratings (self, cache_filename, &error_local)) { + g_debug ("Failed to load cache file ‘%s’, deleting it", cache_filename); + g_file_delete (cache_file, NULL, NULL); + + g_task_return_error (task, g_steal_pointer (&error_local)); + } else { + g_task_return_boolean (task, TRUE); + } + return; + } + } + + /* download the complete file */ + uri = g_strdup_printf ("%s/ratings", self->review_server); + g_debug ("Updating ODRS cache from %s to %s", uri, cache_filename); + + gs_download_file_async (self->session, uri, cache_file, G_PRIORITY_LOW, + progress_callback, progress_user_data, + cancellable, download_ratings_cb, g_steal_pointer (&task)); +} + +static void +download_ratings_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + SoupSession *soup_session = SOUP_SESSION (source_object); + g_autoptr(GTask) task = g_steal_pointer (&user_data); + GsOdrsProvider *self = g_task_get_source_object (task); + GFile *cache_file = g_task_get_task_data (task); + const gchar *cache_file_path = NULL; + g_autoptr(GError) local_error = NULL; + + if (!gs_download_file_finish (soup_session, result, &local_error) && + !g_error_matches (local_error, GS_DOWNLOAD_ERROR, GS_DOWNLOAD_ERROR_NOT_MODIFIED)) { + g_task_return_new_error (task, GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_DOWNLOADING, + "%s", local_error->message); + return; + } + + g_clear_error (&local_error); + + cache_file_path = g_file_peek_path (cache_file); + if (!gs_odrs_provider_load_ratings (self, cache_file_path, &local_error)) { + g_debug ("Failed to load cache file ‘%s’, deleting it", cache_file_path); + g_file_delete (cache_file, NULL, NULL); + + g_task_return_new_error (task, GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "%s", local_error->message); + } else { + g_task_return_boolean (task, TRUE); + } +} + +/** + * gs_odrs_provider_refresh_ratings_finish: + * @self: a #GsOdrsProvider + * @result: result of the asynchronous operation + * @error: return location for a #GError, or %NULL + * + * Finish an asynchronous refresh operation started with + * gs_odrs_provider_refresh_ratings_async(). + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 42 + */ +gboolean +gs_odrs_provider_refresh_ratings_finish (GsOdrsProvider *self, + GAsyncResult *result, + GError **error) +{ + g_return_val_if_fail (GS_IS_ODRS_PROVIDER (self), FALSE); + g_return_val_if_fail (g_task_is_valid (result, self), FALSE); + g_return_val_if_fail (g_task_get_source_tag (G_TASK (result)) == gs_odrs_provider_refresh_ratings_async, FALSE); + g_return_val_if_fail (error == NULL || *error == NULL, FALSE); + + return g_task_propagate_boolean (G_TASK (result), error); +} + +static void refine_app_op (GsOdrsProvider *self, + GTask *task, + GsApp *app, + GsOdrsProviderRefineFlags flags, + GCancellable *cancellable); +static void refine_reviews_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data); +static void finish_refine_op (GTask *task, + GError *error); + +typedef struct { + /* Input data. */ + GsAppList *list; /* (owned) (not nullable) */ + GsOdrsProviderRefineFlags flags; + + /* In-progress data. */ + guint n_pending_ops; + GError *error; /* (nullable) (owned) */ +} RefineData; + +static void +refine_data_free (RefineData *data) +{ + g_assert (data->n_pending_ops == 0); + + g_clear_object (&data->list); + g_clear_error (&data->error); + + g_free (data); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC (RefineData, refine_data_free) + +/** + * gs_odrs_provider_refine_async: + * @self: a #GsOdrsProvider + * @list: list of apps to refine + * @flags: refine flags + * @cancellable: (nullable): a #GCancellable, or %NULL + * @callback: callback for asynchronous completion + * @user_data: data to pass to @callback + * + * Asynchronously refine the given @list of apps to add ratings and review data + * to them, as specified in @flags. + * + * Since: 42 + */ +void +gs_odrs_provider_refine_async (GsOdrsProvider *self, + GsAppList *list, + GsOdrsProviderRefineFlags flags, + GCancellable *cancellable, + GAsyncReadyCallback callback, + gpointer user_data) +{ + g_autoptr(GTask) task = NULL; + g_autoptr(RefineData) data = NULL; + RefineData *data_unowned = NULL; + + task = g_task_new (self, cancellable, callback, user_data); + g_task_set_source_tag (task, gs_odrs_provider_refine_async); + + data_unowned = data = g_new0 (RefineData, 1); + data->list = g_object_ref (list); + data->flags = flags; + g_task_set_task_data (task, g_steal_pointer (&data), (GDestroyNotify) refine_data_free); + + if ((flags & (GS_ODRS_PROVIDER_REFINE_FLAGS_GET_RATINGS | + GS_ODRS_PROVIDER_REFINE_FLAGS_GET_REVIEWS)) == 0) { + g_task_return_boolean (task, TRUE); + return; + } + + /* Mark one operation as pending while all the operations are started, + * so the overall operation can’t complete while things are still being + * started. */ + data_unowned->n_pending_ops++; + + for (guint i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + + /* not valid */ + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_ADDON) + continue; + if (gs_app_get_id (app) == NULL) + continue; + + data_unowned->n_pending_ops++; + refine_app_op (self, task, app, flags, cancellable); + } + + finish_refine_op (task, NULL); +} + +static void +refine_app_op (GsOdrsProvider *self, + GTask *task, + GsApp *app, + GsOdrsProviderRefineFlags flags, + GCancellable *cancellable) +{ + g_autoptr(GError) local_error = NULL; + + /* add ratings if possible */ + if ((flags & GS_ODRS_PROVIDER_REFINE_FLAGS_GET_RATINGS) && + gs_app_get_review_ratings (app) == NULL) { + if (!gs_odrs_provider_refine_ratings (self, app, cancellable, &local_error)) { + if (g_error_matches (local_error, GS_ODRS_PROVIDER_ERROR, GS_ODRS_PROVIDER_ERROR_NO_NETWORK)) { + g_debug ("failed to refine app %s: %s", + gs_app_get_unique_id (app), local_error->message); + } else { + g_prefix_error (&local_error, "failed to refine app: "); + finish_refine_op (task, g_steal_pointer (&local_error)); + return; + } + } + } + + /* add reviews if possible */ + if ((flags & GS_ODRS_PROVIDER_REFINE_FLAGS_GET_REVIEWS) && + gs_app_get_reviews (app)->len == 0) { + /* get from server asynchronously */ + gs_odrs_provider_fetch_reviews_for_app_async (self, app, cancellable, refine_reviews_cb, g_object_ref (task)); + } else { + finish_refine_op (task, NULL); + } +} + +static void +refine_reviews_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + GsOdrsProvider *self = GS_ODRS_PROVIDER (source_object); + g_autoptr(GTask) task = g_steal_pointer (&user_data); + g_autoptr(GError) local_error = NULL; + + if (!gs_odrs_provider_fetch_reviews_for_app_finish (self, result, &local_error)) { + if (g_error_matches (local_error, GS_ODRS_PROVIDER_ERROR, GS_ODRS_PROVIDER_ERROR_NO_NETWORK)) { + g_debug ("failed to refine app: %s", local_error->message); + } else { + g_prefix_error (&local_error, "failed to refine app: "); + finish_refine_op (task, g_steal_pointer (&local_error)); + return; + } + } + + finish_refine_op (task, NULL); +} + +/* @error is (transfer full) if non-NULL. */ +static void +finish_refine_op (GTask *task, + GError *error) +{ + RefineData *data = g_task_get_task_data (task); + g_autoptr(GError) error_owned = g_steal_pointer (&error); + + if (data->error == NULL && error_owned != NULL) + data->error = g_steal_pointer (&error_owned); + else if (error_owned != NULL) + g_debug ("Additional error while refining ODRS data: %s", error_owned->message); + + g_assert (data->n_pending_ops > 0); + data->n_pending_ops--; + + if (data->n_pending_ops == 0) { + if (data->error != NULL) + g_task_return_error (task, g_steal_pointer (&data->error)); + else + g_task_return_boolean (task, TRUE); + } +} + +/** + * gs_odrs_provider_refine_finish: + * @self: a #GsOdrsProvider + * @result: result of the asynchronous operation + * @error: return location for a #GError, or %NULL + * + * Finish an asynchronous refine operation started with + * gs_odrs_provider_refine_finish(). + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 42 + */ +gboolean +gs_odrs_provider_refine_finish (GsOdrsProvider *self, + GAsyncResult *result, + GError **error) +{ + g_return_val_if_fail (GS_IS_ODRS_PROVIDER (self), FALSE); + g_return_val_if_fail (g_task_is_valid (result, self), FALSE); + g_return_val_if_fail (g_async_result_is_tagged (result, gs_odrs_provider_refine_async), FALSE); + g_return_val_if_fail (error == NULL || *error == NULL, FALSE); + + return g_task_propagate_boolean (G_TASK (result), error); +} + +/** + * gs_odrs_provider_submit_review: + * @self: a #GsOdrsProvider + * @app: the app being reviewed + * @review: the review + * @cancellable: (nullable): a #GCancellable, or %NULL + * @error: return location for a #GError + * + * Submit a new @review for @app. + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_odrs_provider_submit_review (GsOdrsProvider *self, + GsApp *app, + AsReview *review, + GCancellable *cancellable, + GError **error) +{ + 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, self->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, setlocale (LC_MESSAGES, NULL)); + json_builder_set_member_name (builder, "distro"); + json_builder_add_string_value (builder, self->distro); + json_builder_set_member_name (builder, "version"); + version = gs_odrs_provider_trim_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_odrs_provider_invalidate_cache (review, error)) + return FALSE; + + /* POST */ + uri = g_strdup_printf ("%s/submit", self->review_server); + if (!gs_odrs_provider_json_post (self->session, uri, data, cancellable, error)) + return FALSE; + + /* modify the local app */ + gs_app_add_review (app, review); + + return TRUE; +} + +/** + * gs_odrs_provider_report_review: + * @self: a #GsOdrsProvider + * @app: the app whose review is being reported + * @review: the review to report + * @cancellable: (nullable): a #GCancellable, or %NULL + * @error: return location for a #GError + * + * Report the given @review on @app for being incorrect or breaking the code of + * conduct. + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_odrs_provider_report_review (GsOdrsProvider *self, + GsApp *app, + AsReview *review, + GCancellable *cancellable, + GError **error) +{ + g_autofree gchar *uri = NULL; + uri = g_strdup_printf ("%s/report", self->review_server); + return gs_odrs_provider_vote (self, review, uri, cancellable, error); +} + +/** + * gs_odrs_provider_upvote_review: + * @self: a #GsOdrsProvider + * @app: the app whose review is being upvoted + * @review: the review to upvote + * @cancellable: (nullable): a #GCancellable, or %NULL + * @error: return location for a #GError + * + * Add one vote to @review on @app. + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_odrs_provider_upvote_review (GsOdrsProvider *self, + GsApp *app, + AsReview *review, + GCancellable *cancellable, + GError **error) +{ + g_autofree gchar *uri = NULL; + uri = g_strdup_printf ("%s/upvote", self->review_server); + return gs_odrs_provider_vote (self, review, uri, cancellable, error); +} + +/** + * gs_odrs_provider_downvote_review: + * @self: a #GsOdrsProvider + * @app: the app whose review is being downvoted + * @review: the review to downvote + * @cancellable: (nullable): a #GCancellable, or %NULL + * @error: return location for a #GError + * + * Remove one vote from @review on @app. + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_odrs_provider_downvote_review (GsOdrsProvider *self, + GsApp *app, + AsReview *review, + GCancellable *cancellable, + GError **error) +{ + g_autofree gchar *uri = NULL; + uri = g_strdup_printf ("%s/downvote", self->review_server); + return gs_odrs_provider_vote (self, review, uri, cancellable, error); +} + +/** + * gs_odrs_provider_dismiss_review: + * @self: a #GsOdrsProvider + * @app: the app whose review is being dismissed + * @review: the review to dismiss + * @cancellable: (nullable): a #GCancellable, or %NULL + * @error: return location for a #GError + * + * Dismiss (ignore) @review on @app when moderating. + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_odrs_provider_dismiss_review (GsOdrsProvider *self, + GsApp *app, + AsReview *review, + GCancellable *cancellable, + GError **error) +{ + g_autofree gchar *uri = NULL; + uri = g_strdup_printf ("%s/dismiss", self->review_server); + return gs_odrs_provider_vote (self, review, uri, cancellable, error); +} + +/** + * gs_odrs_provider_remove_review: + * @self: a #GsOdrsProvider + * @app: the app whose review is being removed + * @review: the review to remove + * @cancellable: (nullable): a #GCancellable, or %NULL + * @error: return location for a #GError + * + * Remove a @review written by the user, from @app. + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_odrs_provider_remove_review (GsOdrsProvider *self, + GsApp *app, + AsReview *review, + GCancellable *cancellable, + GError **error) +{ + g_autofree gchar *uri = NULL; + uri = g_strdup_printf ("%s/remove", self->review_server); + if (!gs_odrs_provider_vote (self, review, uri, cancellable, error)) + return FALSE; + + /* update the local app */ + gs_app_remove_review (app, review); + + return TRUE; +} + +/** + * gs_odrs_provider_add_unvoted_reviews: + * @self: a #GsOdrsProvider + * @list: list of apps to add unvoted reviews to + * @cancellable: (nullable): a #GCancellable, or %NULL + * @error: return location for a #GError + * + * Add the unmoderated reviews for each app in @list to the apps. + * + * Returns: %TRUE on success, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_odrs_provider_add_unvoted_reviews (GsOdrsProvider *self, + GsAppList *list, + GCancellable *cancellable, + GError **error) +{ + guint status_code; + guint i; + gconstpointer downloaded_data; + gsize downloaded_data_length; + g_autofree gchar *uri = NULL; + g_autoptr(GHashTable) hash = NULL; + g_autoptr(JsonParser) json_parser = NULL; + g_autoptr(GPtrArray) reviews = NULL; + g_autoptr(SoupMessage) msg = NULL; +#if SOUP_CHECK_VERSION(3, 0, 0) + g_autoptr(GBytes) bytes = NULL; +#endif + g_autoptr(GError) local_error = 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", + self->review_server, + self->user_hash, + setlocale (LC_MESSAGES, NULL)); + msg = soup_message_new (SOUP_METHOD_GET, uri); +#if SOUP_CHECK_VERSION(3, 0, 0) + bytes = soup_session_send_and_read (self->session, msg, cancellable, error); + if (bytes == NULL) + return FALSE; + + downloaded_data = g_bytes_get_data (bytes, &downloaded_data_length); + status_code = soup_message_get_status (msg); +#else + status_code = soup_session_send_message (self->session, msg); + downloaded_data = msg->response_body ? msg->response_body->data : NULL; + downloaded_data_length = msg->response_body ? msg->response_body->length : 0; +#endif + if (status_code != SOUP_STATUS_OK) { + g_autoptr(GInputStream) input_stream = g_memory_input_stream_new_from_data (downloaded_data, downloaded_data_length, NULL); + if (!gs_odrs_provider_parse_success (input_stream, error)) + return FALSE; + /* not sure what to do here */ + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_DOWNLOADING, + "status code invalid"); + return FALSE; + } + g_debug ("odrs returned: %.*s", (gint) downloaded_data_length, (const gchar *) downloaded_data); + + /* nothing */ + if (downloaded_data == NULL) { + if (!g_network_monitor_get_network_available (g_network_monitor_get_default ())) + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_NO_NETWORK, + "server couldn't be reached"); + else + g_set_error_literal (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "server returned no data"); + return FALSE; + } + + /* parse the data and find the array of ratings */ + json_parser = json_parser_new_immutable (); + if (!json_parser_load_from_data (json_parser, downloaded_data, downloaded_data_length, &local_error)) { + g_set_error (error, + GS_ODRS_PROVIDER_ERROR, + GS_ODRS_PROVIDER_ERROR_PARSING_DATA, + "Error parsing ODRS data: %s", local_error->message); + return FALSE; + } + + reviews = gs_odrs_provider_parse_reviews (self, json_parser, 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, 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_odrs_provider_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; +} -- cgit v1.2.3