*
* Copyright 2025 Alexander Vanhee
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include <glib/gi18n.h>
#include <json-glib/json-glib.h>
#include <libsoup/soup.h>
#include <webkit/webkit.h>
#include "bz-auth-state.h"
#include "bz-flathub-auth-provider.h"
#include "bz-login-page.h"
#include "global-net.h"
#include "template-callbacks.h"
#include "util.h"
struct _BzLoginPage
{
AdwNavigationPage parent_instance;
BzAuthState *auth_state;
WebKitWebView *webview;
gboolean webkit_loaded;
SoupSession *session;
SoupCookieJar *cookie_jar;
GList *providers;
BzFlathubAuthProvider *current_provider;
char *auth_redirect_url;
char *session_cookie;
GDateTime *session_cookie_expires;
gboolean oauth_completed;
GtkStack *main_stack;
AdwStatusPage *error_status_page;
AdwPreferencesGroup *provider_preferences_group;
GtkScrolledWindow *browser_scroll;
};
G_DEFINE_FINAL_TYPE (BzLoginPage, bz_login_page, ADW_TYPE_NAVIGATION_PAGE)
enum
{
PROP_0,
PROP_AUTH_STATE,
N_PROPS
};
static GParamSpec *properties[N_PROPS];
static WebKitNetworkSession *
get_shared_network_session (void)
{
static WebKitNetworkSession *shared_session = NULL;
if (g_once_init_enter (&shared_session))
{
g_autofree char *data_dir = NULL;
g_autoptr (WebKitNetworkSession) session = NULL;
data_dir = g_build_filename (g_get_user_data_dir (),
"io.github.kolunmi.Bazaar",
"webkit-data",
NULL);
session = webkit_network_session_new (data_dir, NULL);
g_once_init_leave (&shared_session, g_steal_pointer (&session));
}
return shared_session;
}
static void
show_error_take (BzLoginPage *self,
char *message)
{
g_autofree char *escaped_message = NULL;
gtk_stack_set_visible_child_name (self->main_stack, "error");
escaped_message = g_markup_escape_text (message, -1);
adw_status_page_set_description (self->error_status_page, escaped_message);
g_free (message);
}
static JsonObject *
parse_json_response (GBytes *bytes,
GError **error)
{
gboolean result = FALSE;
g_autoptr (JsonParser) parser = NULL;
JsonNode *root = NULL;
parser = json_parser_new ();
result = json_parser_load_from_data (
parser,
g_bytes_get_data (bytes, NULL),
g_bytes_get_size (bytes),
error);
if (!result)
return NULL;
root = json_parser_get_root (parser);
return json_node_dup_object (root);
}
static SoupMessage *
create_flathub_request (const char *method,
const char *route)
{
g_autofree char *url = NULL;
g_autoptr (SoupMessage) msg = NULL;
url = g_strdup_printf ("https://flathub.org/api/v2%s", route);
msg = soup_message_new (method, url);
soup_message_headers_append (soup_message_get_request_headers (msg),
"accept", "application/json");
return g_steal_pointer (&msg);
}
static void
load_webkit_library (BzLoginPage *self)
{
WebKitNetworkSession *network_session = NULL;
if (self->webkit_loaded)
return;
network_session = get_shared_network_session ();
self->webview = g_object_new (
WEBKIT_TYPE_WEB_VIEW,
"network-session", network_session,
NULL);
gtk_scrolled_window_set_child (
self->browser_scroll,
GTK_WIDGET (self->webview));
self->webkit_loaded = TRUE;
}
static void
on_user_info_loaded (GObject *source_object,
GAsyncResult *res,
GWeakRef *wr);
static void
on_oauth_complete (GObject *source_object,
GAsyncResult *res,
GWeakRef *wr);
static void
complete_oauth (BzLoginPage *self,
const char *code,
const char *state,
const char *error)
{
g_autoptr (JsonBuilder) builder = NULL;
g_autoptr (JsonGenerator) generator = NULL;
g_autofree char *route = NULL;
g_autofree char *json_data = NULL;
g_autoptr (SoupMessage) msg = NULL;
gtk_stack_set_visible_child_name (self->main_stack, "loading");
builder = json_builder_new ();
json_builder_begin_object (builder);
if (error != NULL)
{
json_builder_set_member_name (builder, "state");
json_builder_add_string_value (builder, state);
json_builder_set_member_name (builder, "error");
json_builder_add_string_value (builder, error);
}
else
{
json_builder_set_member_name (builder, "code");
json_builder_add_string_value (builder, code);
json_builder_set_member_name (builder, "state");
json_builder_add_string_value (builder, state);
}
json_builder_end_object (builder);
generator = json_generator_new ();
json_generator_set_root (generator, json_builder_get_root (builder));
json_data = json_generator_to_data (generator, NULL);
route = g_strdup_printf ("/auth/login/%s",
bz_flathub_auth_provider_get_method (self->current_provider));
msg = soup_message_new ("POST", g_strdup_printf ("https://flathub.org/api/v2%s", route));
soup_message_headers_append (soup_message_get_request_headers (msg),
"accept", "application/json");
soup_message_headers_append (soup_message_get_request_headers (msg),
"Content-Type", "application/json");
soup_message_set_request_body_from_bytes (msg, "application/json",
g_bytes_new (json_data, strlen (json_data)));
soup_session_send_and_read_async (
self->session, msg, G_PRIORITY_DEFAULT,
NULL, (GAsyncReadyCallback) on_oauth_complete,
bz_track_weak (self));
}
static gboolean
on_decide_policy (BzLoginPage *self,
WebKitPolicyDecision *decision,
WebKitPolicyDecisionType decision_type,
WebKitWebView *webview)
{
WebKitNavigationAction *nav_action = NULL;
WebKitURIRequest *request = NULL;
const char *uri = NULL;
g_autoptr (GUri) parsed_uri = NULL;
g_autoptr (GHashTable) params = NULL;
const char *code = NULL;
const char *state = NULL;
const char *error = NULL;
if (decision_type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
return FALSE;
nav_action = webkit_navigation_policy_decision_get_navigation_action (
WEBKIT_NAVIGATION_POLICY_DECISION (decision));
request = webkit_navigation_action_get_request (nav_action);
uri = webkit_uri_request_get_uri (request);
if (uri == NULL || strstr (uri, "flathub.org") == NULL || strstr (uri, "/login/") == NULL)
return FALSE;
parsed_uri = g_uri_parse (uri, G_URI_FLAGS_NONE, NULL);
if (parsed_uri == NULL)
return FALSE;
params = g_uri_parse_params (g_uri_get_query (parsed_uri), -1, "&",
G_URI_PARAMS_NONE, NULL);
if (params == NULL)
return FALSE;
code = g_hash_table_lookup (params, "code");
state = g_hash_table_lookup (params, "state");
error = g_hash_table_lookup (params, "error");
if ((code != NULL && state != NULL && !self->oauth_completed) ||
(error != NULL && state != NULL && !self->oauth_completed))
{
self->oauth_completed = TRUE;
webkit_policy_decision_ignore (decision);
complete_oauth (self, code, state, error);
return TRUE;
}
return FALSE;
}
static void
get_user_info (BzLoginPage *self)
{
g_autoptr (SoupMessage) msg = NULL;
msg = create_flathub_request ("GET", "/auth/userinfo");
soup_session_send_and_read_async (
self->session, msg, G_PRIORITY_DEFAULT,
NULL, (GAsyncReadyCallback) on_user_info_loaded,
bz_track_weak (self));
}
static void
on_oauth_complete (GObject *source_object,
GAsyncResult *res,
GWeakRef *wr)
{
g_autoptr (BzLoginPage) self = NULL;
g_autoptr (GError) local_error = NULL;
g_autoptr (GBytes) bytes = NULL;
g_autoptr (JsonObject) obj = NULL;
const char *status = NULL;
g_autoslist (SoupCookie) cookies = NULL;
self = g_weak_ref_get (wr);
if (self == NULL)
goto done;
bytes = soup_session_send_and_read_finish (
SOUP_SESSION (source_object), res, &local_error);
if (bytes == NULL)
{
g_warning ("OAuth complete error: %s", local_error->message);
show_error_take (self, g_strdup_printf ("Error: %s", local_error->message));
goto done;
}
obj = parse_json_response (bytes, &local_error);
if (obj == NULL)
{
g_warning ("Failed to parse OAuth response: %s", local_error->message);
show_error_take (self, g_strdup_printf ("Error: %s", local_error->message));
goto done;
}
status = json_object_get_string_member (obj, "status");
cookies = soup_cookie_jar_all_cookies (self->cookie_jar);
for (GSList *l = cookies; l != NULL; l = l->next)
{
SoupCookie *cookie = l->data;
if (g_strcmp0 (soup_cookie_get_name (cookie), "session") == 0)
{
g_clear_pointer (&self->session_cookie, g_free);
self->session_cookie = g_strdup (soup_cookie_get_value (cookie));
g_clear_pointer (&self->session_cookie_expires, g_date_time_unref);
self->session_cookie_expires = g_date_time_ref (soup_cookie_get_expires (cookie));
}
}
if (g_strcmp0 (status, "ok") == 0 || g_strcmp0 (status, "success") == 0)
get_user_info (self);
else
show_error_take (self, g_strdup ("Authentication failed"));
done:
bz_weak_release (wr);
}
static void
on_user_info_loaded (GObject *source_object,
GAsyncResult *res,
GWeakRef *wr)
{
g_autoptr (BzLoginPage) self = NULL;
g_autoptr (GBytes) bytes = NULL;
g_autoptr (GError) error = NULL;
g_autoptr (JsonObject) obj = NULL;
JsonObject *default_account = NULL;
const char *displayname = NULL;
const char *avatar_url = NULL;
self = g_weak_ref_get (wr);
if (self == NULL)
goto done;
if (self->webview != NULL)
webkit_web_view_load_uri (self->webview, "about:blank");
bytes = soup_session_send_and_read_finish (SOUP_SESSION (source_object), res, &error);
if (error != NULL)
{
g_warning ("User info load error: %s", error->message);
show_error_take (self, g_strdup_printf ("Error: %s", error->message));
goto done;
}
obj = parse_json_response (bytes, &error);
if (obj == NULL)
{
g_warning ("Failed to parse user info: %s", error->message);
show_error_take (self, g_strdup_printf ("Error: %s", error->message));
goto done;
}
displayname = json_object_get_string_member (obj, "displayname");
if (json_object_has_member (obj, "default_account"))
{
default_account = json_object_get_object_member (obj, "default_account");
if (displayname == NULL && json_object_has_member (default_account, "login"))
displayname = json_object_get_string_member (default_account, "login");
if (json_object_has_member (default_account, "avatar"))
avatar_url = json_object_get_string_member (default_account, "avatar");
}
if (displayname == NULL)
displayname = "N/A";
if (self->auth_state != NULL)
bz_auth_state_set_authenticated (self->auth_state,
displayname,
self->session_cookie,
self->session_cookie_expires,
avatar_url);
gtk_stack_set_visible_child_name (self->main_stack, "finish");
done:
bz_weak_release (wr);
}
static void
on_login_response (GObject *source_object,
GAsyncResult *res,
GWeakRef *wr)
{
g_autoptr (BzLoginPage) self = NULL;
g_autoptr (GError) local_error = NULL;
g_autoptr (GBytes) bytes = NULL;
g_autoptr (JsonObject) obj = NULL;
const char *redirect = NULL;
self = g_weak_ref_get (wr);
if (self == NULL)
goto done;
bytes = soup_session_send_and_read_finish (
SOUP_SESSION (source_object), res, &local_error);
if (bytes == NULL)
{
show_error_take (self, g_strdup_printf ("Error: %s", local_error->message));
goto done;
}
obj = parse_json_response (bytes, &local_error);
if (obj == NULL)
{
show_error_take (self, g_strdup_printf ("Error: %s", local_error->message));
goto done;
}
redirect = json_object_get_string_member (obj, "redirect");
if (redirect == NULL)
{
show_error_take (self, g_strdup ("Error: No redirect URL received"));
goto done;
}
g_clear_pointer (&self->auth_redirect_url, g_free);
self->auth_redirect_url = g_strdup (redirect);
load_webkit_library (self);
g_signal_connect_swapped (
self->webview, "decide-policy",
G_CALLBACK (on_decide_policy), self);
gtk_stack_set_visible_child_name (self->main_stack, "browser");
webkit_web_view_load_uri (self->webview, self->auth_redirect_url);
done:
bz_weak_release (wr);
}
static void
on_provider_row_activated (BzLoginPage *self,
GtkButton *button)
{
BzFlathubAuthProvider *provider = NULL;
g_autoptr (SoupMessage) msg = NULL;
g_autofree char *route = NULL;
provider = g_object_get_data (G_OBJECT (button), "provider");
if (provider == NULL)
return;
self->current_provider = provider;
self->oauth_completed = FALSE;
gtk_stack_set_visible_child_name (self->main_stack, "loading");
route = g_strdup_printf ("/auth/login/%s",
bz_flathub_auth_provider_get_method (provider));
msg = create_flathub_request ("GET", route);
soup_session_send_and_read_async (
self->session, msg, G_PRIORITY_DEFAULT,
NULL, (GAsyncReadyCallback) on_login_response,
bz_track_weak (self));
}
static void
on_providers_loaded (GObject *source_object,
GAsyncResult *res,
GWeakRef *wr)
{
g_autoptr (BzLoginPage) self = NULL;
g_autoptr (GError) local_error = NULL;
g_autoptr (GBytes) bytes = NULL;
g_autoptr (JsonParser) parser = NULL;
JsonNode *root;
JsonArray *array;
self = g_weak_ref_get (wr);
if (self == NULL)
goto done;
bytes = soup_session_send_and_read_finish (SOUP_SESSION (source_object), res, &local_error);
if (bytes == NULL)
{
show_error_take (self, g_strdup_printf ("Error loading providers: %s", local_error->message));
goto done;
}
parser = json_parser_new ();
if (!json_parser_load_from_data (parser,
g_bytes_get_data (bytes, NULL),
g_bytes_get_size (bytes),
&local_error))
{
show_error_take (self, g_strdup_printf ("Error parsing providers: %s", local_error->message));
goto done;
}
root = json_parser_get_root (parser);
array = json_node_get_array (root);
for (guint i = 0; i < json_array_get_length (array); i++)
{
JsonObject *provider_obj = NULL;
g_autoptr (BzFlathubAuthProvider) provider = NULL;
const char *method = NULL;
const char *name = NULL;
GtkWidget *row = NULL;
GtkWidget *prefix_icon = NULL;
GtkWidget *suffix_icon = NULL;
g_autofree char *icon_name = NULL;
provider_obj = json_array_get_object_element (array, i);
provider = bz_flathub_auth_provider_new ();
method = json_object_get_string_member (provider_obj, "method");
name = json_object_get_string_member (provider_obj, "name");
row = adw_action_row_new ();
icon_name = g_strdup_printf ("io.github.kolunmi.Bazaar.login-%s", method);
bz_flathub_auth_provider_set_name (provider, name);
bz_flathub_auth_provider_set_method (provider, method);
self->providers = g_list_append (self->providers, g_object_ref (provider));
adw_preferences_row_set_title (ADW_PREFERENCES_ROW (row), name);
prefix_icon = gtk_image_new_from_icon_name (icon_name);
gtk_image_set_icon_size (GTK_IMAGE (prefix_icon), GTK_ICON_SIZE_LARGE);
gtk_widget_add_css_class (prefix_icon, "lowres-icon");
adw_action_row_add_prefix (ADW_ACTION_ROW (row), prefix_icon);
suffix_icon = gtk_image_new_from_icon_name ("go-next-symbolic");
adw_action_row_add_suffix (ADW_ACTION_ROW (row), suffix_icon);
g_object_set_data_full (G_OBJECT (row), "provider", g_object_ref (provider), g_object_unref);
gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), true);
g_signal_connect_swapped (row, "activated", G_CALLBACK (on_provider_row_activated), self);
adw_preferences_group_add (self->provider_preferences_group, row);
}
gtk_stack_set_visible_child_name (self->main_stack, "start");
done:
bz_weak_release (wr);
}
static void
load_providers (BzLoginPage *self)
{
g_autoptr (SoupMessage) msg = NULL;
gtk_stack_set_visible_child_name (self->main_stack, "loading");
msg = create_flathub_request ("GET", "/auth/login");
soup_session_send_and_read_async (
self->session, msg, G_PRIORITY_DEFAULT,
NULL, (GAsyncReadyCallback) on_providers_loaded,
bz_track_weak (self));
}
static void
on_close_clicked (GtkButton *button,
BzLoginPage *self)
{
GtkWidget *navigation_view = NULL;
navigation_view = gtk_widget_get_ancestor (GTK_WIDGET (self),
ADW_TYPE_NAVIGATION_VIEW);
if (navigation_view != NULL)
adw_navigation_view_pop (ADW_NAVIGATION_VIEW (navigation_view));
}
static void
bz_login_page_dispose (GObject *object)
{
BzLoginPage *self = BZ_LOGIN_PAGE (object);
g_clear_object (&self->auth_state);
g_clear_object (&self->session);
g_clear_object (&self->cookie_jar);
g_clear_pointer (&self->session_cookie_expires, g_date_time_unref);
g_clear_pointer (&self->auth_redirect_url, g_free);
g_clear_pointer (&self->session_cookie, g_free);
if (self->providers != NULL)
{
g_list_free_full (self->providers, (GDestroyNotify) g_object_unref);
self->providers = NULL;
}
G_OBJECT_CLASS (bz_login_page_parent_class)->dispose (object);
}
static void
bz_login_page_get_property (GObject *object,
guint prop_id,
GValue *value,
GParamSpec *pspec)
{
BzLoginPage *self = BZ_LOGIN_PAGE (object);
switch (prop_id)
{
case PROP_AUTH_STATE:
g_value_set_object (value, self->auth_state);
break;
default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
}
}
static void
bz_login_page_set_property (GObject *object,
guint prop_id,
const GValue *value,
GParamSpec *pspec)
{
BzLoginPage *self = BZ_LOGIN_PAGE (object);
switch (prop_id)
{
case PROP_AUTH_STATE:
g_set_object (&self->auth_state, g_value_get_object (value));
break;
default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
}
}
static char *
format_greeting (gpointer object,
const char *name)
{
if (name == NULL || name[0] == '\0')
return g_strdup (" ");
return g_strdup_printf (_ ("Hello, %s!"), name);
}
static void
bz_login_page_class_init (BzLoginPageClass *klass)
{
GObjectClass *object_class = G_OBJECT_CLASS (klass);
GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
object_class->dispose = bz_login_page_dispose;
object_class->get_property = bz_login_page_get_property;
object_class->set_property = bz_login_page_set_property;
properties[PROP_AUTH_STATE] =
g_param_spec_object (
"auth-state",
NULL, NULL,
BZ_TYPE_AUTH_STATE,
G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
g_object_class_install_properties (object_class, N_PROPS, properties);
gtk_widget_class_set_template_from_resource (widget_class,
"/io/github/kolunmi/Bazaar/bz-login-page.ui");
bz_widget_class_bind_all_util_callbacks (widget_class);
gtk_widget_class_bind_template_child (widget_class, BzLoginPage, main_stack);
gtk_widget_class_bind_template_child (widget_class, BzLoginPage, error_status_page);
gtk_widget_class_bind_template_child (widget_class, BzLoginPage, provider_preferences_group);
gtk_widget_class_bind_template_child (widget_class, BzLoginPage, browser_scroll);
gtk_widget_class_bind_template_callback (widget_class, on_close_clicked);
gtk_widget_class_bind_template_callback (widget_class, format_greeting);
}
static void
bz_login_page_init (BzLoginPage *self)
{
gtk_widget_init_template (GTK_WIDGET (self));
self->session = soup_session_new ();
soup_session_set_proxy_resolver (self->session, bz_get_default_proxy_resolver ());
self->cookie_jar = soup_cookie_jar_new ();
soup_cookie_jar_set_accept_policy (self->cookie_jar, SOUP_COOKIE_JAR_ACCEPT_ALWAYS);
soup_session_add_feature (self->session, SOUP_SESSION_FEATURE (self->cookie_jar));
self->webkit_loaded = FALSE;
self->oauth_completed = FALSE;
load_providers (self);
}
AdwNavigationPage *
bz_login_page_new (BzAuthState *auth_state)
{
return g_object_new (BZ_TYPE_LOGIN_PAGE,
"auth-state", auth_state,
NULL);
}