From dd6e64ad305e1ab181c6276ed4c77d910a36a4fa Mon Sep 17 00:00:00 2001 From: Matthew Barnes Date: Thu, 9 Jun 2011 16:28:36 -0400 Subject: Prototype an online-accounts module. Integrates with the GNOME Online Accounts service. Creates Evolution sources for a GOA Google account and keeps them synchronized. Also registers a new CamelSaslXOAuth class for use with GMail. Authentication of Google Calendars and Google Contacts using OAuth is still under development. --- modules/online-accounts/Makefile.am | 32 ++ modules/online-accounts/camel-sasl-xoauth.c | 504 ++++++++++++++++++++ modules/online-accounts/camel-sasl-xoauth.h | 63 +++ modules/online-accounts/e-online-accounts-google.c | 455 ++++++++++++++++++ modules/online-accounts/e-online-accounts-google.h | 32 ++ .../online-accounts/evolution-online-accounts.c | 520 +++++++++++++++++++++ 6 files changed, 1606 insertions(+) create mode 100644 modules/online-accounts/Makefile.am create mode 100644 modules/online-accounts/camel-sasl-xoauth.c create mode 100644 modules/online-accounts/camel-sasl-xoauth.h create mode 100644 modules/online-accounts/e-online-accounts-google.c create mode 100644 modules/online-accounts/e-online-accounts-google.h create mode 100644 modules/online-accounts/evolution-online-accounts.c (limited to 'modules/online-accounts') diff --git a/modules/online-accounts/Makefile.am b/modules/online-accounts/Makefile.am new file mode 100644 index 0000000000..a5dfd97a0b --- /dev/null +++ b/modules/online-accounts/Makefile.am @@ -0,0 +1,32 @@ +module_LTLIBRARIES = libevolution-module-online-accounts.la + +libevolution_module_online_accounts_la_CPPFLAGS = \ + $(AM_CPPFLAGS) \ + -I$(top_srcdir) \ + -DG_LOG_DOMAIN=\"evolution-online-accounts\" \ + $(GNOME_PLATFORM_CFLAGS) \ + $(EVOLUTION_ADDRESSBOOK_CFLAGS) \ + $(EVOLUTION_CALENDAR_CFLAGS) \ + $(EVOLUTION_MAIL_CFLAGS) \ + $(GOA_CFLAGS) + +libevolution_module_online_accounts_la_SOURCES = \ + evolution-online-accounts.c \ + e-online-accounts-google.c \ + e-online-accounts-google.h \ + camel-sasl-xoauth.c \ + camel-sasl-xoauth.h + +libevolution_module_online_accounts_la_LIBADD = \ + $(top_builddir)/e-util/libeutil.la \ + $(top_builddir)/shell/libeshell.la \ + $(GNOME_PLATFORM_LIBS) \ + $(EVOLUTION_ADDRESSBOOK_LIBS) \ + $(EVOLUTION_CALENDAR_LIBS) \ + $(EVOLUTION_MAIL_LIBS) \ + $(GOA_LIBS) + +libevolution_module_online_accounts_la_LDFLAGS = \ + -module -avoid-version $(NO_UNDEFINED) + +-include $(top_srcdir)/git.mk diff --git a/modules/online-accounts/camel-sasl-xoauth.c b/modules/online-accounts/camel-sasl-xoauth.c new file mode 100644 index 0000000000..9ce391214d --- /dev/null +++ b/modules/online-accounts/camel-sasl-xoauth.c @@ -0,0 +1,504 @@ +/* + * camel-sasl-xoauth.c + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) version 3. + * + * 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with the program; if not, see + * + */ + +#include "camel-sasl-xoauth.h" + +#include +#include + +#define GOA_API_IS_SUBJECT_TO_CHANGE +#include + +#define CAMEL_SASL_XOAUTH_GET_PRIVATE(obj) \ + (G_TYPE_INSTANCE_GET_PRIVATE \ + ((obj), CAMEL_TYPE_SASL_XOAUTH, CamelSaslXOAuthPrivate)) + +/* This is the property name or URL parameter under which we + * embed the GoaAccount ID into an EAccount or ESource object. */ +#define GOA_KEY "goa-account-id" + +struct _CamelSaslXOAuthPrivate { + gint placeholder; +}; + +G_DEFINE_DYNAMIC_TYPE (CamelSaslXOAuth, camel_sasl_xoauth, CAMEL_TYPE_SASL) + +/***************************************************************************** + * This is based on an old revision of gnome-online-accounts + * which demonstrated OAuth authentication with an IMAP server. + * + * See commit 5bcbe2a3eac4821892680e0655b27ab8c128ab15 + *****************************************************************************/ + +#include + +#define OAUTH_ENCODE_STRING(str) \ + (str ? soup_uri_encode ((str), "!$&'()*+,;=@") : g_strdup ("")) + +#define SHA1_BLOCK_SIZE 64 +#define SHA1_LENGTH 20 + +/* + * hmac_sha1: + * @key: The key + * @message: The message + * + * Given the key and message, compute the HMAC-SHA1 hash and return the base-64 + * encoding of it. This is very geared towards OAuth, and as such both key and + * message must be NULL-terminated strings, and the result is base-64 encoded. + */ +static gchar * +hmac_sha1 (const gchar *key, + const gchar *message) +{ + GChecksum *checksum; + gchar *real_key; + guchar ipad[SHA1_BLOCK_SIZE]; + guchar opad[SHA1_BLOCK_SIZE]; + guchar inner[SHA1_LENGTH]; + guchar digest[SHA1_LENGTH]; + gsize key_length, inner_length, digest_length; + int i; + + g_return_val_if_fail (key, NULL); + g_return_val_if_fail (message, NULL); + + checksum = g_checksum_new (G_CHECKSUM_SHA1); + + /* If the key is longer than the block size, hash it first */ + if (strlen (key) > SHA1_BLOCK_SIZE) { + guchar new_key[SHA1_LENGTH]; + + key_length = sizeof (new_key); + + g_checksum_update (checksum, (guchar*)key, strlen (key)); + g_checksum_get_digest (checksum, new_key, &key_length); + g_checksum_reset (checksum); + + real_key = g_memdup (new_key, key_length); + } else { + real_key = g_strdup (key); + key_length = strlen (key); + } + + /* Sanity check the length */ + g_assert (key_length <= SHA1_BLOCK_SIZE); + + /* Protect against use of the provided key by NULLing it */ + key = NULL; + + /* Stage 1 */ + memset (ipad, 0, sizeof (ipad)); + memset (opad, 0, sizeof (opad)); + + memcpy (ipad, real_key, key_length); + memcpy (opad, real_key, key_length); + + /* Stage 2 and 5 */ + for (i = 0; i < sizeof (ipad); i++) { + ipad[i] ^= 0x36; + opad[i] ^= 0x5C; + } + + /* Stage 3 and 4 */ + g_checksum_update (checksum, ipad, sizeof (ipad)); + g_checksum_update (checksum, (guchar*)message, strlen (message)); + inner_length = sizeof (inner); + g_checksum_get_digest (checksum, inner, &inner_length); + g_checksum_reset (checksum); + + /* Stage 6 and 7 */ + g_checksum_update (checksum, opad, sizeof (opad)); + g_checksum_update (checksum, inner, inner_length); + + digest_length = sizeof (digest); + g_checksum_get_digest (checksum, digest, &digest_length); + + g_checksum_free (checksum); + g_free (real_key); + + return g_base64_encode (digest, digest_length); +} + +static char * +sign_plaintext (const gchar *consumer_secret, + const gchar *token_secret) +{ + gchar *cs; + gchar *ts; + gchar *rv; + + cs = OAUTH_ENCODE_STRING (consumer_secret); + ts = OAUTH_ENCODE_STRING (token_secret); + rv = g_strconcat (cs, "&", ts, NULL); + + g_free (cs); + g_free (ts); + + return rv; +} + +static char * +sign_hmac (const gchar *consumer_secret, + const gchar *token_secret, + const gchar *http_method, + const gchar *request_uri, + const gchar *encoded_params) +{ + GString *text; + gchar *signature; + gchar *key; + + text = g_string_new (NULL); + g_string_append (text, http_method); + g_string_append_c (text, '&'); + g_string_append_uri_escaped (text, request_uri, NULL, FALSE); + g_string_append_c (text, '&'); + g_string_append_uri_escaped (text, encoded_params, NULL, FALSE); + + /* PLAINTEXT signature value is the HMAC-SHA1 key value */ + key = sign_plaintext (consumer_secret, token_secret); + signature = hmac_sha1 (key, text->str); + g_free (key); + + g_string_free (text, TRUE); + + return signature; +} + +static GHashTable * +calculate_xoauth_params (const gchar *request_uri, + const gchar *consumer_key, + const gchar *consumer_secret, + const gchar *access_token, + const gchar *access_token_secret) +{ + gchar *signature; + GHashTable *params; + gchar *nonce; + gchar *timestamp; + GList *keys; + GList *iter; + GString *normalized; + gpointer key; + + nonce = g_strdup_printf ("%u", g_random_int ()); + timestamp = g_strdup_printf ( + "%" G_GINT64_FORMAT, (gint64) time (NULL)); + + params = g_hash_table_new_full ( + (GHashFunc) g_str_hash, + (GEqualFunc) g_str_equal, + (GDestroyNotify) NULL, + (GDestroyNotify) g_free); + + key = (gpointer) "oauth_consumer_key"; + g_hash_table_insert (params, key, g_strdup (consumer_key)); + + key = (gpointer) "oauth_nonce"; + g_hash_table_insert (params, key, nonce); /* takes ownership */ + + key = (gpointer) "oauth_timestamp"; + g_hash_table_insert (params, key, timestamp); /* takes ownership */ + + key = (gpointer) "oauth_version"; + g_hash_table_insert (params, key, g_strdup ("1.0")); + + key = (gpointer) "oauth_signature_method"; + g_hash_table_insert (params, key, g_strdup ("HMAC-SHA1")); + + key = (gpointer) "oauth_token"; + g_hash_table_insert (params, key, g_strdup (access_token)); + + normalized = g_string_new (NULL); + keys = g_hash_table_get_keys (params); + keys = g_list_sort (keys, (GCompareFunc) g_strcmp0); + for (iter = keys; iter != NULL; iter = iter->next) { + const gchar *key = iter->data; + const gchar *value; + gchar *k; + gchar *v; + + value = g_hash_table_lookup (params, key); + if (normalized->len > 0) + g_string_append_c (normalized, '&'); + + k = OAUTH_ENCODE_STRING (key); + v = OAUTH_ENCODE_STRING (value); + + g_string_append_printf (normalized, "%s=%s", k, v); + + g_free (k); + g_free (v); + } + g_list_free (keys); + + signature = sign_hmac ( + consumer_secret, access_token_secret, + "GET", request_uri, normalized->str); + + key = (gpointer) "oauth_signature"; + g_hash_table_insert (params, key, signature); /* takes ownership */ + + g_string_free (normalized, TRUE); + + return params; +} + +static gchar * +calculate_xoauth_param (const gchar *request_uri, + const gchar *consumer_key, + const gchar *consumer_secret, + const gchar *access_token, + const gchar *access_token_secret) +{ + GString *str; + GHashTable *params; + GList *keys; + GList *iter; + + params = calculate_xoauth_params ( + request_uri, + consumer_key, + consumer_secret, + access_token, + access_token_secret); + + str = g_string_new ("GET "); + g_string_append (str, request_uri); + g_string_append_c (str, ' '); + keys = g_hash_table_get_keys (params); + keys = g_list_sort (keys, (GCompareFunc) g_strcmp0); + for (iter = keys; iter != NULL; iter = iter->next) { + const gchar *key = iter->data; + const gchar *value; + gchar *k; + gchar *v; + + value = g_hash_table_lookup (params, key); + if (iter != keys) + g_string_append_c (str, ','); + + k = OAUTH_ENCODE_STRING (key); + v = OAUTH_ENCODE_STRING (value); + g_string_append_printf (str, "%s=\"%s\"", k, v); + g_free (k); + g_free (v); + } + g_list_free (keys); + + g_hash_table_unref (params); + + return g_string_free (str, FALSE); +} + +/****************************************************************************/ + +static GoaObject * +sasl_xoauth_get_account_by_id (GoaClient *client, + const gchar *account_id) +{ + GoaObject *match = NULL; + GList *list, *iter; + + list = goa_client_get_accounts (client); + + for (iter = list; iter != NULL; iter = g_list_next (iter)) { + GoaObject *goa_object; + GoaAccount *goa_account; + const gchar *candidate_id; + + goa_object = GOA_OBJECT (iter->data); + goa_account = goa_object_get_account (goa_object); + candidate_id = goa_account_get_id (goa_account); + + if (g_strcmp0 (account_id, candidate_id) == 0) + match = g_object_ref (goa_object); + + g_object_unref (goa_account); + + if (match != NULL) + break; + } + + g_list_free_full (list, (GDestroyNotify) g_object_unref); + + return match; +} + +static GByteArray * +sasl_xoauth_challenge_sync (CamelSasl *sasl, + GByteArray *token, + GCancellable *cancellable, + GError **error) +{ + GoaClient *goa_client; + GoaObject *goa_object; + GoaAccount *goa_account; + GByteArray *parameters = NULL; + CamelService *service; + CamelURL *url; + const gchar *account_id; + gchar *xoauth_param = NULL; + gboolean success; + + service = camel_sasl_get_service (sasl); + url = camel_service_get_camel_url (service); + account_id = camel_url_get_param (url, GOA_KEY); + g_return_val_if_fail (account_id != NULL, NULL); + + goa_client = goa_client_new_sync (cancellable, error); + if (goa_client == NULL) + return NULL; + + goa_object = sasl_xoauth_get_account_by_id (goa_client, account_id); + if (goa_object == NULL) { + g_set_error_literal ( + error, CAMEL_SERVICE_ERROR, + CAMEL_SERVICE_ERROR_CANT_AUTHENTICATE, + _("Cannot find a corresponding account in " + "the org.gnome.OnlineAccounts service from " + "which to obtain an authentication token.")); + g_object_unref (goa_client); + return NULL; + } + + goa_account = goa_object_get_account (goa_object); + + success = goa_account_call_ensure_credentials_sync ( + goa_account, NULL, cancellable, error); + + if (success) { + GoaOAuthBased *goa_oauth_based; + const gchar *identity; + const gchar *consumer_key; + const gchar *consumer_secret; + const gchar *service_type; + gchar *access_token = NULL; + gchar *access_token_secret = NULL; + gchar *request_uri; + + goa_oauth_based = goa_object_get_oauth_based (goa_object); + + identity = goa_account_get_identity (goa_account); + service_type = CAMEL_IS_STORE (service) ? "imap" : "smtp"; + + /* FIXME This should probably be generalized. */ + request_uri = g_strdup_printf ( + "https://mail.google.com/mail/b/%s/%s/", + identity, service_type); + + consumer_key = + goa_oauth_based_get_consumer_key (goa_oauth_based); + consumer_secret = + goa_oauth_based_get_consumer_secret (goa_oauth_based); + + success = goa_oauth_based_call_get_access_token_sync ( + goa_oauth_based, + &access_token, + &access_token_secret, + NULL, + cancellable, + error); + + if (success) + xoauth_param = calculate_xoauth_param ( + request_uri, + consumer_key, + consumer_secret, + access_token, + access_token_secret); + + g_free (access_token); + g_free (access_token_secret); + g_free (request_uri); + + g_object_unref (goa_oauth_based); + } + + g_object_unref (goa_account); + g_object_unref (goa_object); + g_object_unref (goa_client); + + if (success) { + /* Sanity check. */ + g_return_val_if_fail (xoauth_param != NULL, NULL); + + parameters = g_byte_array_new (); + g_byte_array_append ( + parameters, (guint8 *) xoauth_param, + strlen (xoauth_param) + 1); + g_free (xoauth_param); + } + + /* IMAP and SMTP services will Base64-encode the XOAUTH parameters. */ + + return parameters; +} + +static gpointer +camel_sasl_xoauth_auth_type_init (gpointer unused) +{ + CamelServiceAuthType *auth_type; + + /* This is a one-time allocation, never freed. */ + auth_type = g_malloc0 (sizeof (CamelServiceAuthType)); + auth_type->name = _("OAuth"); + auth_type->description = + _("This option will connect to the server by " + "way of the GNOME Online Accounts service"); + auth_type->authproto = "XOAUTH"; + auth_type->need_password = FALSE; + + return auth_type; +} + +static void +camel_sasl_xoauth_class_init (CamelSaslXOAuthClass *class) +{ + static GOnce auth_type_once = G_ONCE_INIT; + CamelSaslClass *sasl_class; + + g_once (&auth_type_once, camel_sasl_xoauth_auth_type_init, NULL); + + g_type_class_add_private (class, sizeof (CamelSaslXOAuthPrivate)); + + sasl_class = CAMEL_SASL_CLASS (class); + sasl_class->auth_type = auth_type_once.retval; + sasl_class->challenge_sync = sasl_xoauth_challenge_sync; +} + +static void +camel_sasl_xoauth_class_finalize (CamelSaslXOAuthClass *class) +{ +} + +static void +camel_sasl_xoauth_init (CamelSaslXOAuth *sasl) +{ + sasl->priv = CAMEL_SASL_XOAUTH_GET_PRIVATE (sasl); +} + +void +camel_sasl_xoauth_type_register (GTypeModule *type_module) +{ + /* XXX G_DEFINE_DYNAMIC_TYPE declares a static type registration + * function, so we have to wrap it with a public function in + * order to register types from a separate compilation unit. */ + camel_sasl_xoauth_register_type (type_module); +} diff --git a/modules/online-accounts/camel-sasl-xoauth.h b/modules/online-accounts/camel-sasl-xoauth.h new file mode 100644 index 0000000000..3e78547e1b --- /dev/null +++ b/modules/online-accounts/camel-sasl-xoauth.h @@ -0,0 +1,63 @@ +/* + * camel-sasl-xoauth.h + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) version 3. + * + * 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with the program; if not, see + * + */ + +#ifndef CAMEL_SASL_XOAUTH_H +#define CAMEL_SASL_XOAUTH_H + +#include + +/* Standard GObject macros */ +#define CAMEL_TYPE_SASL_XOAUTH \ + (camel_sasl_xoauth_get_type ()) +#define CAMEL_SASL_XOAUTH(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST \ + ((obj), CAMEL_TYPE_SASL_XOAUTH, CamelSaslXOAuth)) +#define CAMEL_SASL_XOAUTH_CLASS(cls) \ + (G_TYPE_CHECK_CLASS_CAST \ + ((cls), CAMEL_TYPE_SASL_XOAUTH, CamelSaslXOAuthClass)) +#define CAMEL_IS_SASL_XOAUTH(obj) \ + (G_TYPE_CHECK_INSTANCE_TYPE \ + ((obj), CAMEL_TYPE_SASL_XOAUTH)) +#define CAMEL_IS_SASL_XOAUTH_CLASS(cls) \ + (G_TYPE_CHECK_CLASS_TYPE \ + ((cls), CAMEL_TYPE_SASL_XOAUTH)) +#define CAMEL_SASL_XOAUTH_GET_CLASS(obj) \ + (G_TYPE_INSTANCE_GET_CLASS \ + ((obj), CAMEL_TYPE_SASL_XOAUTH, CamelSaslXOAuthClass)) + +G_BEGIN_DECLS + +typedef struct _CamelSaslXOAuth CamelSaslXOAuth; +typedef struct _CamelSaslXOAuthClass CamelSaslXOAuthClass; +typedef struct _CamelSaslXOAuthPrivate CamelSaslXOAuthPrivate; + +struct _CamelSaslXOAuth { + CamelSasl parent; + CamelSaslXOAuthPrivate *priv; +}; + +struct _CamelSaslXOAuthClass { + CamelSaslClass parent_class; +}; + +GType camel_sasl_xoauth_get_type (void); +void camel_sasl_xoauth_type_register (GTypeModule *type_module); + +G_END_DECLS + +#endif /* CAMEL_SASL_XOAUTH_H */ diff --git a/modules/online-accounts/e-online-accounts-google.c b/modules/online-accounts/e-online-accounts-google.c new file mode 100644 index 0000000000..32280792dc --- /dev/null +++ b/modules/online-accounts/e-online-accounts-google.c @@ -0,0 +1,455 @@ +/* + * e-online-accounts-google.c + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) version 3. + * + * 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with the program; if not, see + * + */ + +#include "e-online-accounts-google.h" + +#include +#include + +/* XXX Just use the deprecated APIs for now. + * We'll be switching away soon enough. */ +#undef E_CAL_DISABLE_DEPRECATED +#undef E_BOOK_DISABLE_DEPRECATED + +#include +#include + +#include + +/* This is the property name or URL parameter under which we + * embed the GoaAccount ID into an EAccount or ESource object. */ +#define GOA_KEY "goa-account-id" + +#define GOOGLE_BASE_URI "google://" + +/** + * XXX Once the key-file based ESource API is merged, I'd + * like to structure the ESources something like this: + * + * * Maybe add an "Enabled" key to the [Data Source] group, + * so we have an easy way to hide/show individual sources + * without destroying custom settings. Would replace the + * "Enabled" key in [Mail Account], and rename the same + * key in ESourceSelectable to "Active". + * + * +---------------------------------------------------+ + * | [Data Source] | + * | DisplayName: <> | + * | Backend: google | + * | Enabled: true # What would 'false' mean? | + * | | + * | [GNOME Online Accounts] | + * | Id: <> | + * +---------------------------------------------------+ + * | + * | (child ESources) + * | + * | +------------------------------------------+ + * | | [Data Source] | + * | | DisplayName: (same as parent) | + * | | Enabled: true | + * | | | + * | | [Authentication] | + * | | Host: imap.gmail.com | + * | | blah, blah, blah... | + * | | | + * +----| [Mail Account] | + * | | blah, blah, blah... | + * | | | + * | | [Mail Identity] | + * | | Name: Matthew Barnes | + * | | Address: <> | + * | | blah, blah, blah... | + * | +------------------------------------------+ + * | + * | +------------------------------------------+ + * | | [Data Source] | + * | | DisplayName: GMail SMTP Server | + * | | Enabled: true | + * | | | + * | | [Authentication] | + * | | Host: smtp.gmail.com | + * +----| blah, blah, blah... | + * | | | + * | | [Mail Transport] | + * | | blah, blah, blah... | + * | +------------------------------------------+ + * | + * | +------------------------------------------+ + * | | [Data Source] | + * | | DisplayName: Contacts | + * | | Enabled: true | + * | | | + * | | [Authentication] | + * | | blah, blah, blah... | + * +----| | + * | | [Address Book] | + * | | blah, blah, blah... | + * | +------------------------------------------+ + * | + * | +------------------------------------------+ + * | | [Data Source] | + * | | DisplayName: Calendar | + * | | Backend: caldav | + * | | Enabled: true | + * | | | + * | | [Authentication] | + * | | blah, blah, blah... | + * +----| | + * | [Calendar] | + * | blah, blah, blah... | + * +------------------------------------------+ + */ + +/* XXX Copy part of the private struct here so we can set our own UID. + * Since EAccountList and ESourceList forces the different aspects + * of the Google account to be disjoint, we can reuse the UID to + * link them back together. */ +struct _ESourcePrivate { + ESourceGroup *group; + + gchar *uid; + /* ... yadda, yadda, yadda ... */ +}; + +static void +online_accounts_google_sync_mail (GoaObject *goa_object, + const gchar *evo_id) +{ + GoaMail *goa_mail; + GoaAccount *goa_account; + EAccountList *account_list; + EAccount *account; + CamelURL *url; + const gchar *string; + gboolean new_account = FALSE; + + /* XXX There's nothing particularly GMail-specific about this. + * Maybe break this off into a more generic IMAP/SMTP sync + * function and then apply any GMail-specific tweaks. */ + + goa_mail = goa_object_get_mail (goa_object); + goa_account = goa_object_get_account (goa_object); + + account_list = e_get_account_list (); + account = e_get_account_by_uid (evo_id); + + if (account == NULL) { + account = g_object_new (E_TYPE_ACCOUNT, NULL); + account->uid = g_strdup (evo_id); + account->enabled = TRUE; + new_account = TRUE; + } + + /*** Account Name ***/ + + g_free (account->name); + string = goa_account_get_presentation_identity (goa_account); + account->name = g_strdup (string); + + /*** Mail Identity ***/ + + if (account->id->name == NULL) + account->id->name = g_strdup (g_get_real_name ()); + + g_free (account->id->address); + string = goa_mail_get_email_address (goa_mail); + account->id->address = g_strdup (string); + + /*** Mail Storage ***/ + + /* This quietly handles NULL strings sanely. */ + url = camel_url_new (account->source->url, NULL); + + if (url == NULL) + url = g_new0 (CamelURL, 1); + + camel_url_set_protocol (url, "imapx"); + + string = goa_account_get_identity (goa_account); + camel_url_set_user (url, string); + + string = goa_mail_get_imap_host (goa_mail); + camel_url_set_host (url, string); + + /* Use CamelSaslXOAuth. */ + camel_url_set_authmech (url, "XOAUTH"); + + /* Always == SSL (port 993) */ + if (goa_mail_get_imap_use_tls (goa_mail)) + camel_url_set_param (url, "use_ssl", "always"); + else + camel_url_set_param (url, "use_ssl", "never"); + + string = goa_account_get_id (goa_account); + camel_url_set_param (url, GOA_KEY, string); + + g_free (account->source->url); + account->source->url = camel_url_to_string (url, 0); + + camel_url_free (url); + + /*** Mail Transport ***/ + + /* This quietly handles NULL strings sanely. */ + url = camel_url_new (account->transport->url, NULL); + + if (url == NULL) + url = g_new0 (CamelURL, 1); + + camel_url_set_protocol (url, "smtp"); + + string = goa_account_get_identity (goa_account); + camel_url_set_user (url, string); + + string = goa_mail_get_smtp_host (goa_mail); + camel_url_set_host (url, string); + + /* Message Submission port */ + camel_url_set_port (url, 587); + + /* Use CamelSaslXOAuth. */ + camel_url_set_authmech (url, "XOAUTH"); + + /* When-Possible == STARTTLS */ + if (goa_mail_get_smtp_use_tls (goa_mail)) + camel_url_set_param (url, "use_ssl", "when-possible"); + else + camel_url_set_param (url, "use_ssl", "never"); + + string = goa_account_get_id (goa_account); + camel_url_set_param (url, GOA_KEY, string); + + g_free (account->transport->url); + account->transport->url = camel_url_to_string (url, 0); + + camel_url_free (url); + + /* Clean up. */ + + if (new_account) { + e_account_list_add (account_list, account); + g_object_unref (account); + } + + g_object_unref (goa_account); + g_object_unref (goa_mail); +} + +static void +online_accounts_google_sync_calendar (GoaObject *goa_object, + const gchar *evo_id) +{ + GoaAccount *goa_account; + ESourceList *source_list = NULL; + ESourceGroup *source_group; + ECalSourceType source_type; + ESource *source; + const gchar *string; + gchar *encoded; + gchar *uri_string; + gboolean new_source = FALSE; + GError *error = NULL; + + source_type = E_CAL_SOURCE_TYPE_EVENT; + + if (!e_cal_get_sources (&source_list, source_type, &error)) { + g_warn_if_fail (source_list == NULL); + g_warn_if_fail (error != NULL); + g_warning ("%s", error->message); + g_error_free (error); + return; + } + + g_return_if_fail (E_IS_SOURCE_LIST (source_list)); + + goa_account = goa_object_get_account (goa_object); + + /* This returns a new reference to the source group. */ + source_group = e_source_list_ensure_group ( + source_list, _("Google"), GOOGLE_BASE_URI, TRUE); + + source = e_source_group_peek_source_by_uid (source_group, evo_id); + + if (source == NULL) { + source = g_object_new (E_TYPE_SOURCE, NULL); + source->priv->uid = g_strdup (evo_id); + e_source_set_name (source, _("Calendar")); + new_source = TRUE; + } + + string = goa_account_get_identity (goa_account); + + encoded = camel_url_encode (string, "@"); + uri_string = g_strdup_printf ( + "caldav://%s@www.google.com/calendar/dav/%s/events", + encoded, string); + e_source_set_absolute_uri (source, uri_string); + g_free (uri_string); + g_free (encoded); + + e_source_set_property (source, "ssl", "1"); + e_source_set_property (source, "username", string); + e_source_set_property (source, "setup-username", string); + + /* XXX Not sure this needs to be set since the backend + * will authenticate itself if it sees a GOA ID. */ + e_source_set_property (source, "auth", "1"); + + if (new_source) { + e_source_group_add_source (source_group, source, -1); + g_object_unref (source); + } + + g_object_unref (source_group); + g_object_unref (goa_account); +} + +static void +online_accounts_google_sync_contacts (GoaObject *goa_object, + const gchar *evo_id) +{ + GoaAccount *goa_account; + ESourceList *source_list = NULL; + ESourceGroup *source_group; + ESource *source; + const gchar *string; + gboolean new_source = FALSE; + GError *error = NULL; + + if (!e_book_get_addressbooks (&source_list, &error)) { + g_warn_if_fail (source_list == NULL); + g_warn_if_fail (error != NULL); + g_warning ("%s", error->message); + g_error_free (error); + return; + } + + g_return_if_fail (E_IS_SOURCE_LIST (source_list)); + + goa_account = goa_object_get_account (goa_object); + + /* This returns a new reference to the source group. */ + source_group = e_source_list_ensure_group ( + source_list, _("Google"), GOOGLE_BASE_URI, TRUE); + + source = e_source_group_peek_source_by_uid (source_group, evo_id); + + if (source == NULL) { + source = g_object_new (E_TYPE_SOURCE, NULL); + source->priv->uid = g_strdup (evo_id); + e_source_set_name (source, _("Contacts")); + new_source = TRUE; + } + + string = goa_account_get_identity (goa_account); + + e_source_set_relative_uri (source, string); + + e_source_set_property (source, "use-ssl", "true"); + e_source_set_property (source, "username", string); + + /* XXX Not sure this needs to be set since the backend + * will authenticate itself if it sees a GOA ID. */ + e_source_set_property (source, "auth", "plain/password"); + + if (new_source) { + e_source_group_add_source (source_group, source, -1); + g_object_unref (source); + } + + g_object_unref (source_group); + g_object_unref (goa_account); +} + +void +e_online_accounts_google_sync (GoaObject *goa_object, + const gchar *evo_id) +{ + GoaMail *goa_mail; + GoaCalendar *goa_calendar; + GoaContacts *goa_contacts; + + g_return_if_fail (GOA_IS_OBJECT (goa_object)); + g_return_if_fail (evo_id != NULL); + + /*** Google Mail ***/ + + goa_mail = goa_object_get_mail (goa_object); + if (goa_mail != NULL) { + online_accounts_google_sync_mail (goa_object, evo_id); + g_object_unref (goa_mail); + } else { + EAccountList *account_list; + EAccount *account; + + account_list = e_get_account_list (); + account = e_get_account_by_uid (evo_id); + + if (account != NULL) + e_account_list_remove (account_list, account); + } + + /*** Google Calendar ***/ + + goa_calendar = goa_object_get_calendar (goa_object); + if (goa_calendar != NULL) { + online_accounts_google_sync_calendar (goa_object, evo_id); + g_object_unref (goa_calendar); + } else { + ESourceList *source_list = NULL; + ECalSourceType source_type; + GError *error = NULL; + + source_type = E_CAL_SOURCE_TYPE_EVENT; + if (e_cal_get_sources (&source_list, source_type, &error)) { + e_source_list_remove_source_by_uid ( + source_list, evo_id); + g_object_unref (source_list); + } else { + g_warn_if_fail (source_list == NULL); + g_warn_if_fail (error != NULL); + g_warning ("%s", error->message); + g_error_free (error); + } + + /* XXX Would be nice to support Google Tasks as well. */ + } + + /*** Google Contacts ***/ + + goa_contacts = goa_object_get_contacts (goa_object); + if (goa_contacts != NULL) { + online_accounts_google_sync_contacts (goa_object, evo_id); + g_object_unref (goa_contacts); + } else { + ESourceList *source_list = NULL; + GError *error = NULL; + + if (e_book_get_addressbooks (&source_list, &error)) { + e_source_list_remove_source_by_uid ( + source_list, evo_id); + g_object_unref (source_list); + } else { + g_warn_if_fail (source_list == NULL); + g_warn_if_fail (error != NULL); + g_warning ("%s", error->message); + g_error_free (error); + } + } +} diff --git a/modules/online-accounts/e-online-accounts-google.h b/modules/online-accounts/e-online-accounts-google.h new file mode 100644 index 0000000000..10973f3298 --- /dev/null +++ b/modules/online-accounts/e-online-accounts-google.h @@ -0,0 +1,32 @@ +/* + * e-online-accounts-google.h + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) version 3. + * + * 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with the program; if not, see + * + */ + +#ifndef E_ONLINE_ACCOUNTS_GOOGLE_H +#define E_ONLINE_ACCOUNTS_GOOGLE_H + +#define GOA_API_IS_SUBJECT_TO_CHANGE +#include + +G_BEGIN_DECLS + +void e_online_accounts_google_sync (GoaObject *goa_object, + const gchar *evo_id); + +G_END_DECLS + +#endif /* E_ONLINE_ACCOUNTS_GOOGLE_H */ diff --git a/modules/online-accounts/evolution-online-accounts.c b/modules/online-accounts/evolution-online-accounts.c new file mode 100644 index 0000000000..d127f4d096 --- /dev/null +++ b/modules/online-accounts/evolution-online-accounts.c @@ -0,0 +1,520 @@ +/* + * evolution-online-accounts.c + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) version 3. + * + * 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with the program; if not, see + * + */ + +#include +#include + +/* XXX Just use the deprecated APIs for now. + * We'll be switching away soon enough. */ +#undef E_CAL_DISABLE_DEPRECATED +#undef E_BOOK_DISABLE_DEPRECATED + +#include +#include +#include +#include + +#include +#include + +#include "camel-sasl-xoauth.h" +#include "e-online-accounts-google.h" + +/* Standard GObject macros */ +#define E_TYPE_ONLINE_ACCOUNTS \ + (e_online_accounts_get_type ()) +#define E_ONLINE_ACCOUNTS(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST \ + ((obj), E_TYPE_ONLINE_ACCOUNTS, EOnlineAccounts)) + +/* This is the property name or URL parameter under which we + * embed the GoaAccount ID into an EAccount or ESource object. */ +#define GOA_KEY "goa-account-id" + +typedef struct _EOnlineAccounts EOnlineAccounts; +typedef struct _EOnlineAccountsClass EOnlineAccountsClass; + +typedef struct _AccountNode AccountNode; + +struct _EOnlineAccounts { + EExtension parent; + + /* GoaAccount ID -> EAccount/ESource ID */ + GHashTable *accounts; + + GoaClient *goa_client; + EActivity *connecting; +}; + +struct _EOnlineAccountsClass { + EExtensionClass parent_class; +}; + +struct _AccountNode { + gchar *goa_id; /* GoaAccount ID */ + gchar *evo_id; /* EAccount/ESource ID */ +}; + +/* Module Entry Points */ +void e_module_load (GTypeModule *type_module); +void e_module_unload (GTypeModule *type_module); + +/* Forward Declarations */ +GType e_online_accounts_get_type (void); + +G_DEFINE_DYNAMIC_TYPE (EOnlineAccounts, e_online_accounts, E_TYPE_EXTENSION) + +static EShell * +online_accounts_get_shell (EOnlineAccounts *extension) +{ + EExtensible *extensible; + + extensible = e_extension_get_extensible (E_EXTENSION (extension)); + + return E_SHELL (extensible); +} + +static void +online_accounts_account_added_cb (GoaClient *goa_client, + GoaObject *goa_object, + EOnlineAccounts *extension) +{ + GoaAccount *goa_account; + const gchar *provider_type; + const gchar *goa_id; + const gchar *evo_id; + + goa_account = goa_object_get_account (goa_object); + provider_type = goa_account_get_provider_type (goa_account); + + goa_id = goa_account_get_id (goa_account); + evo_id = g_hash_table_lookup (extension->accounts, goa_id); + + if (g_strcmp0 (provider_type, "google") == 0) { + if (evo_id == NULL) { + gchar *uid = e_uid_new (); + g_hash_table_insert ( + extension->accounts, + g_strdup (goa_id), uid); + evo_id = uid; + } + + e_online_accounts_google_sync (goa_object, evo_id); + } + + g_object_unref (goa_account); +} + +static void +online_accounts_account_changed_cb (GoaClient *goa_client, + GoaObject *goa_object, + EOnlineAccounts *extension) +{ + /* XXX We'll be able to handle changes more sanely once we have + * key-file based ESources with proper change notifications. */ + online_accounts_account_added_cb (goa_client, goa_object, extension); +} + +static void +online_accounts_account_removed_cb (GoaClient *goa_client, + GoaObject *goa_object, + EOnlineAccounts *extension) +{ + GoaAccount *goa_account; + EAccountList *account_list; + ESourceList *source_list; + ECalSourceType type; + EAccount *account; + const gchar *goa_id; + const gchar *evo_id; + + goa_account = goa_object_get_account (goa_object); + goa_id = goa_account_get_id (goa_account); + evo_id = g_hash_table_lookup (extension->accounts, goa_id); + + if (evo_id == NULL) + goto exit; + + /* Remove the mail account. */ + + account_list = e_get_account_list (); + account = e_get_account_by_uid (evo_id); + + if (account != NULL) + e_account_list_remove (account_list, account); + + /* Remove the address book. */ + + if (e_book_get_addressbooks (&source_list, NULL)) { + e_source_list_remove_source_by_uid (source_list, evo_id); + g_object_unref (source_list); + } + + /* Remove the calendar. */ + + for (type = 0; type < E_CAL_SOURCE_TYPE_LAST; type++) { + if (e_cal_get_sources (&source_list, type, NULL)) { + e_source_list_remove_source_by_uid ( + source_list, evo_id); + g_object_unref (source_list); + } + } + +exit: + g_object_unref (goa_account); +} + +static gint +online_accounts_compare_id (GoaObject *goa_object, + const gchar *goa_id) +{ + GoaAccount *goa_account; + gint result; + + goa_account = goa_object_get_account (goa_object); + result = g_strcmp0 (goa_account_get_id (goa_account), goa_id); + g_object_unref (goa_account); + + return result; +} + +static void +online_accounts_handle_uid (EOnlineAccounts *extension, + const gchar *goa_id, + const gchar *evo_id) +{ + const gchar *match; + + /* If the GNOME Online Account ID is already registered, the + * corresponding Evolution ID better match what was passed in. */ + match = g_hash_table_lookup (extension->accounts, goa_id); + g_return_if_fail (match == NULL || g_strcmp0 (match, evo_id) == 0); + + if (match == NULL) + g_hash_table_insert ( + extension->accounts, + g_strdup (goa_id), + g_strdup (evo_id)); +} + +static void +online_accounts_search_source_list (EOnlineAccounts *extension, + GList *goa_objects, + ESourceList *source_list) +{ + GSList *list_a; + + list_a = e_source_list_peek_groups (source_list); + + while (list_a != NULL) { + ESourceGroup *source_group; + GQueue trash = G_QUEUE_INIT; + GSList *list_b; + + source_group = E_SOURCE_GROUP (list_a->data); + list_a = g_slist_next (list_a); + + list_b = e_source_group_peek_sources (source_group); + + while (list_b != NULL) { + ESource *source; + const gchar *property; + const gchar *uid; + GList *match; + + source = E_SOURCE (list_b->data); + list_b = g_slist_next (list_b); + + uid = e_source_peek_uid (source); + property = e_source_get_property (source, GOA_KEY); + + if (property == NULL) + continue; + + /* Verify the GOA account still exists. */ + match = g_list_find_custom ( + goa_objects, property, (GCompareFunc) + online_accounts_compare_id); + + /* If a matching GoaObject was found, add its ID + * to our accounts hash table. Otherwise remove + * the ESource after we finish looping. */ + if (match != NULL) + online_accounts_handle_uid ( + extension, property, uid); + else + g_queue_push_tail (&trash, source); + } + + /* Empty the trash. */ + while (!g_queue_is_empty (&trash)) { + ESource *source = g_queue_pop_head (&trash); + e_source_group_remove_source (source_group, source); + } + } +} + +static void +online_accounts_populate_accounts_table (EOnlineAccounts *extension, + GList *goa_objects) +{ + EAccountList *account_list; + ESourceList *source_list; + EIterator *iterator; + ECalSourceType type; + GQueue trash = G_QUEUE_INIT; + + /* XXX All this messy logic will be much prettier once the new + * key-file based ESource API is merged. For now though, + * we trudge through it the old and cumbersome way. */ + + /* Search mail accounts. */ + + account_list = e_get_account_list (); + iterator = e_list_get_iterator (E_LIST (account_list)); + + while (e_iterator_is_valid (iterator)) { + EAccount *account; + CamelURL *url; + const gchar *param; + + /* XXX EIterator misuses const. */ + account = (EAccount *) e_iterator_get (iterator); + e_iterator_next (iterator); + + if (account->source == NULL) + continue; + + if (account->source->url == NULL) + continue; + + url = camel_url_new (account->source->url, NULL); + if (url == NULL) + continue; + + param = camel_url_get_param (url, GOA_KEY); + if (param != NULL) { + GList *match; + + /* Verify the GOA account still exists. */ + match = g_list_find_custom ( + goa_objects, param, (GCompareFunc) + online_accounts_compare_id); + + /* If a matching GoaObject was found, add its ID + * to our accounts hash table. Otherwise remove + * the EAccount after we finish looping. */ + if (match != NULL) + online_accounts_handle_uid ( + extension, param, account->uid); + else + g_queue_push_tail (&trash, account); + } + + camel_url_free (url); + } + + g_object_unref (iterator); + + /* Empty the trash. */ + while (!g_queue_is_empty (&trash)) { + EAccount *account = g_queue_pop_head (&trash); + e_account_list_remove (account_list, account); + } + + /* Search address book sources. */ + + if (e_book_get_addressbooks (&source_list, NULL)) { + online_accounts_search_source_list ( + extension, goa_objects, source_list); + g_object_unref (source_list); + } + + /* Search calendar-related sources. */ + + for (type = 0; type < E_CAL_SOURCE_TYPE_LAST; type++) { + if (e_cal_get_sources (&source_list, type, NULL)) { + online_accounts_search_source_list ( + extension, goa_objects, source_list); + g_object_unref (source_list); + } + } +} + +static void +online_accounts_connect_done (GObject *source_object, + GAsyncResult *result, + EOnlineAccounts *extension) +{ + GList *list, *link; + GError *error = NULL; + + extension->goa_client = goa_client_new_finish (result, &error); + + /* FIXME Add an EAlert for this? */ + if (error != NULL) { + g_warning ("%s", error->message); + g_error_free (error); + return; + } + + list = goa_client_get_accounts (extension->goa_client); + + /* This populates a hash table of GOA ID -> Evo ID strings by + * searching through all Evolution sources for ones tagged with + * a GOA ID. If a GOA ID tag is found, but no corresponding GOA + * account (presumably meaning the GOA account was deleted between + * Evo sessions), then the EAccount or ESource on which the tag was + * found gets deleted. */ + online_accounts_populate_accounts_table (extension, list); + + for (link = list; link != NULL; link = g_list_next (link)) + online_accounts_account_added_cb ( + extension->goa_client, + GOA_OBJECT (link->data), + extension); + + g_list_free_full (list, (GDestroyNotify) g_object_unref); + + /* Listen for Online Account changes. */ + g_signal_connect ( + extension->goa_client, "account-added", + G_CALLBACK (online_accounts_account_added_cb), extension); + g_signal_connect ( + extension->goa_client, "account-changed", + G_CALLBACK (online_accounts_account_changed_cb), extension); + g_signal_connect ( + extension->goa_client, "account-removed", + G_CALLBACK (online_accounts_account_removed_cb), extension); + + /* This will allow the Evolution Setup Assistant to proceed. */ + g_object_unref (extension->connecting); + extension->connecting = NULL; +} + +static void +online_accounts_connect (EShell *shell, + EActivity *activity, + EOnlineAccounts *extension) +{ + /* This will inhibit the Evolution Setup Assistant until + * we've synchronized with the OnlineAccounts service. */ + extension->connecting = g_object_ref (activity); + + /* We don't really need to reference the extension in the + * async closure since its lifetime is bound to the EShell. */ + goa_client_new ( + NULL, (GAsyncReadyCallback) + online_accounts_connect_done, extension); +} + +static void +online_accounts_dispose (GObject *object) +{ + EOnlineAccounts *extension; + + extension = E_ONLINE_ACCOUNTS (object); + + /* This should never fail... in theory. */ + g_warn_if_fail (extension->connecting == NULL); + + if (extension->goa_client != NULL) { + g_signal_handlers_disconnect_matched ( + extension->goa_client, G_SIGNAL_MATCH_DATA, + 0, 0, NULL, NULL, object); + g_object_unref (extension->goa_client); + extension->goa_client = NULL; + } + + /* Chain up to parent's dispose() method. */ + G_OBJECT_CLASS (e_online_accounts_parent_class)->dispose (object); +} + +static void +online_accounts_finalize (GObject *object) +{ + EOnlineAccounts *extension; + + extension = E_ONLINE_ACCOUNTS (object); + + g_hash_table_destroy (extension->accounts); + + /* Chain up to parent's finalize() method. */ + G_OBJECT_CLASS (e_online_accounts_parent_class)->finalize (object); +} + +static void +online_accounts_constructed (GObject *object) +{ + EOnlineAccounts *extension; + EShell *shell; + + extension = E_ONLINE_ACCOUNTS (object); + shell = online_accounts_get_shell (extension); + + /* This event is emitted from the "startup-wizard" module. */ + g_signal_connect ( + shell, "event::load-accounts", + G_CALLBACK (online_accounts_connect), extension); + + /* Chain up to parent's constructed() method. */ + G_OBJECT_CLASS (e_online_accounts_parent_class)->constructed (object); +} + +static void +e_online_accounts_class_init (EOnlineAccountsClass *class) +{ + GObjectClass *object_class; + EExtensionClass *extension_class; + + object_class = G_OBJECT_CLASS (class); + object_class->dispose = online_accounts_dispose; + object_class->finalize = online_accounts_finalize; + object_class->constructed = online_accounts_constructed; + + extension_class = E_EXTENSION_CLASS (class); + extension_class->extensible_type = E_TYPE_SHELL; +} + +static void +e_online_accounts_class_finalize (EOnlineAccountsClass *class) +{ +} + +static void +e_online_accounts_init (EOnlineAccounts *extension) +{ + extension->accounts = g_hash_table_new_full ( + (GHashFunc) g_str_hash, + (GEqualFunc) g_str_equal, + (GDestroyNotify) g_free, + (GDestroyNotify) g_free); +} + +G_MODULE_EXPORT void +e_module_load (GTypeModule *type_module) +{ + e_online_accounts_register_type (type_module); + camel_sasl_xoauth_type_register (type_module); +} + +G_MODULE_EXPORT void +e_module_unload (GTypeModule *type_module) +{ +} + -- cgit