1 /*
2  * e-editor-web-extension.c
3  *
4  * This program is free software; you can redistribute it and/or
5  * modify it under the terms of the GNU Lesser General Public
6  * License as published by the Free Software Foundation; either
7  * version 2 of the License, or (at your option) version 3.
8  *
9  * This program is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12  * Lesser General Public License for more details.
13  *
14  * You should have received a copy of the GNU Lesser General Public
15  * License along with the program; if not, see <http://www.gnu.org/licenses/>
16  *
17  */
18 
19 #include "evolution-config.h"
20 
21 #include <webkit2/webkit-web-extension.h>
22 
23 #include <libedataserver/libedataserver.h>
24 
25 #define E_UTIL_INCLUDE_WITHOUT_WEBKIT 1
26 #include "e-util/e-util.h"
27 #undef E_UTIL_INCLUDE_WITHOUT_WEBKIT
28 
29 #include "e-editor-web-extension.h"
30 
31 struct _EEditorWebExtensionPrivate {
32 	WebKitWebExtension *wk_extension;
33 	ESpellChecker *spell_checker;
34 };
35 
G_DEFINE_TYPE_WITH_PRIVATE(EEditorWebExtension,e_editor_web_extension,G_TYPE_OBJECT)36 G_DEFINE_TYPE_WITH_PRIVATE (EEditorWebExtension, e_editor_web_extension, G_TYPE_OBJECT)
37 
38 static void
39 e_editor_web_extension_dispose (GObject *object)
40 {
41 	EEditorWebExtension *extension = E_EDITOR_WEB_EXTENSION (object);
42 
43 	g_clear_object (&extension->priv->wk_extension);
44 	g_clear_object (&extension->priv->spell_checker);
45 
46 	/* Chain up to parent's dispose() method. */
47 	G_OBJECT_CLASS (e_editor_web_extension_parent_class)->dispose (object);
48 }
49 
50 static void
e_editor_web_extension_class_init(EEditorWebExtensionClass * class)51 e_editor_web_extension_class_init (EEditorWebExtensionClass *class)
52 {
53 	GObjectClass *object_class = G_OBJECT_CLASS (class);
54 
55 	object_class->dispose = e_editor_web_extension_dispose;
56 }
57 
58 static void
e_editor_web_extension_init(EEditorWebExtension * extension)59 e_editor_web_extension_init (EEditorWebExtension *extension)
60 {
61 	extension->priv = e_editor_web_extension_get_instance_private (extension);
62 	extension->priv->spell_checker = NULL;
63 }
64 
65 static gpointer
e_editor_web_extension_create_instance(gpointer data)66 e_editor_web_extension_create_instance (gpointer data)
67 {
68 	return g_object_new (E_TYPE_EDITOR_WEB_EXTENSION, NULL);
69 }
70 
71 EEditorWebExtension *
e_editor_web_extension_get_default(void)72 e_editor_web_extension_get_default (void)
73 {
74 	static GOnce once_init = G_ONCE_INIT;
75 	return E_EDITOR_WEB_EXTENSION (g_once (&once_init, e_editor_web_extension_create_instance, NULL));
76 }
77 
78 static gboolean
use_sources_js_file(void)79 use_sources_js_file (void)
80 {
81 	static gint res = -1;
82 
83 	if (res == -1)
84 		res = g_strcmp0 (g_getenv ("E_HTML_EDITOR_TEST_SOURCES"), "1") == 0 ? 1 : 0;
85 
86 	return res;
87 }
88 
89 static void
load_javascript_file(JSCContext * jsc_context,const gchar * js_filename)90 load_javascript_file (JSCContext *jsc_context,
91 		      const gchar *js_filename)
92 {
93 	JSCValue *result;
94 	JSCException *exception;
95 	gchar *content, *filename = NULL, *resource_uri;
96 	gsize length = 0;
97 	GError *error = NULL;
98 
99 	g_return_if_fail (jsc_context != NULL);
100 
101 	if (use_sources_js_file ()) {
102 		const gchar *source_webkitdatadir;
103 
104 		source_webkitdatadir = g_getenv ("EVOLUTION_SOURCE_WEBKITDATADIR");
105 
106 		if (source_webkitdatadir && *source_webkitdatadir) {
107 			filename = g_build_filename (source_webkitdatadir, js_filename, NULL);
108 
109 			if (!g_file_test (filename, G_FILE_TEST_EXISTS)) {
110 				g_warning ("Cannot find '%s', using installed file '%s/%s' instead", filename, EVOLUTION_WEBKITDATADIR, js_filename);
111 
112 				g_clear_pointer (&filename, g_free);
113 			}
114 		} else {
115 			g_warning ("Environment variable 'EVOLUTION_SOURCE_WEBKITDATADIR' not set or invalid value, using installed file '%s/%s' instead", EVOLUTION_WEBKITDATADIR, js_filename);
116 		}
117 	}
118 
119 	if (!filename)
120 		filename = g_build_filename (EVOLUTION_WEBKITDATADIR, js_filename, NULL);
121 
122 	if (!g_file_get_contents (filename, &content, &length, &error)) {
123 		g_warning ("Failed to load '%s': %s", filename, error ? error->message : "Unknown error");
124 
125 		g_clear_error (&error);
126 		g_free (filename);
127 
128 		return;
129 	}
130 
131 	resource_uri = g_strconcat ("resource:///", js_filename, NULL);
132 
133 	result = jsc_context_evaluate_with_source_uri (jsc_context, content, length, resource_uri, 1);
134 
135 	g_free (resource_uri);
136 
137 	exception = jsc_context_get_exception (jsc_context);
138 
139 	if (exception) {
140 		g_warning ("Failed to call script '%s': %d:%d: %s",
141 			filename,
142 			jsc_exception_get_line_number (exception),
143 			jsc_exception_get_column_number (exception),
144 			jsc_exception_get_message (exception));
145 
146 		jsc_context_clear_exception (jsc_context);
147 	}
148 
149 	g_clear_object (&result);
150 	g_free (filename);
151 	g_free (content);
152 }
153 
154 static void
evo_editor_find_pattern(const gchar * text,const gchar * pattern,gint * out_start,gint * out_end)155 evo_editor_find_pattern (const gchar *text,
156 			 const gchar *pattern,
157 			 gint *out_start,
158 			 gint *out_end)
159 {
160 	GRegex *regex;
161 
162 	g_return_if_fail (out_start != NULL);
163 	g_return_if_fail (out_end != NULL);
164 
165 	*out_start = -1;
166 	*out_end = -1;
167 
168 	regex = g_regex_new (pattern, 0, 0, NULL);
169 	if (regex) {
170 		GMatchInfo *match_info = NULL;
171 		gint start = -1, end = -1;
172 
173 		if (g_regex_match_all (regex, text, G_REGEX_MATCH_NOTEMPTY, &match_info) &&
174 		    g_match_info_fetch_pos (match_info, 0, &start, &end) &&
175 		    start >= 0 && end >= 0) {
176 			*out_start = start;
177 			*out_end = end;
178 		}
179 
180 		if (match_info)
181 			g_match_info_free (match_info);
182 		g_regex_unref (regex);
183 	}
184 }
185 
186 /* Returns 'null', when no match for magicLinks in 'text' were found, otherwise
187    returns an array of 'object { text : string, [ href : string] };' with the text
188    split into parts, where those with also 'href' property defined are meant
189    to be anchors. */
190 static JSCValue *
evo_editor_jsc_split_text_with_links(const gchar * text,JSCContext * jsc_context)191 evo_editor_jsc_split_text_with_links (const gchar *text,
192 				      JSCContext *jsc_context)
193 {
194 	/* stephenhay from https://mathiasbynens.be/demo/url-regex */
195 	const gchar *URL_PATTERN = "((?:(?:(?:"
196 				   "news|telnet|nntp|file|https?|s?ftp|webcal|localhost|ssh"
197 				   ")\\:\\/\\/)|(?:www\\.|ftp\\.))[^\\s\\/\\$\\.\\?#].[^\\s]*+)";
198 	/* from camel-url-scanner.c */
199 	const gchar *URL_INVALID_TRAILING_CHARS = ",.:;?!-|}])\">";
200 	/* http://www.w3.org/TR/html5/forms.html#valid-e-mail-address */
201 	const gchar *EMAIL_PATTERN = "[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}"
202 				     "[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*+";
203 	JSCValue *array = NULL;
204 	guint array_len = 0;
205 	gboolean done = FALSE;
206 
207 	if (!text || !*text)
208 		return jsc_value_new_null (jsc_context);
209 
210 	#define add_to_array(_obj) G_STMT_START { \
211 		if (!array) \
212 			array = jsc_value_new_array (jsc_context, G_TYPE_NONE); \
213 		jsc_value_object_set_property_at_index (array, array_len, _obj); \
214 		array_len++; \
215 		} G_STMT_END
216 
217 	while (!done) {
218 		gboolean is_email;
219 		gint start = -1, end = -1;
220 
221 		done = TRUE;
222 
223 		is_email = strchr (text, '@') && !strstr (text, "://");
224 
225 		evo_editor_find_pattern (text, is_email ? EMAIL_PATTERN : URL_PATTERN, &start, &end);
226 
227 		if (start >= 0 && end >= 0) {
228 			const gchar *url_end, *ptr;
229 
230 			url_end = text + end - 1;
231 
232 			/* Stop on the angle brackets, which cannot be part of the URL (see RFC 3986 Appendix C) */
233 			for (ptr = text + start; ptr <= url_end; ptr++) {
234 				if (*ptr == '<' || *ptr == '>') {
235 					end = ptr - text;
236 					url_end = text + end - 1;
237 					break;
238 				}
239 			}
240 
241 			/* URLs are extremely unlikely to end with any punctuation, so
242 			 * strip any trailing punctuation off from link and put it after
243 			 * the link. Do the same for any closing double-quotes as well. */
244 			while (end > start && *url_end && strchr (URL_INVALID_TRAILING_CHARS, *url_end)) {
245 				gchar open_bracket = 0, close_bracket = *url_end;
246 
247 				if (close_bracket == ')')
248 					open_bracket = '(';
249 				else if (close_bracket == '}')
250 					open_bracket = '{';
251 				else if (close_bracket == ']')
252 					open_bracket = '[';
253 				else if (close_bracket == '>')
254 					open_bracket = '<';
255 
256 				if (open_bracket != 0) {
257 					gint n_opened = 0, n_closed = 0;
258 
259 					for (ptr = text + start; ptr <= url_end; ptr++) {
260 						if (*ptr == open_bracket)
261 							n_opened++;
262 						else if (*ptr == close_bracket)
263 							n_closed++;
264 					}
265 
266 					/* The closing bracket can match one inside the URL,
267 					   thus keep it there. */
268 					if (n_opened > 0 && n_opened - n_closed >= 0)
269 						break;
270 				}
271 
272 				url_end--;
273 				end--;
274 			}
275 
276 			if (end > start) {
277 				JSCValue *object, *string;
278 				gchar *url, *tmp;
279 
280 				if (start > 0) {
281 					tmp = g_strndup (text, start);
282 
283 					object = jsc_value_new_object (jsc_context, NULL, NULL);
284 
285 					string = jsc_value_new_string (jsc_context, tmp);
286 					jsc_value_object_set_property (object, "text", string);
287 					g_clear_object (&string);
288 
289 					add_to_array (object);
290 
291 					g_clear_object (&object);
292 					g_free (tmp);
293 				}
294 
295 				tmp = g_strndup (text + start, end - start);
296 
297 				if (is_email)
298 					url = g_strconcat ("mailto:", tmp, NULL);
299 				else if (g_str_has_prefix (tmp, "www."))
300 					url = g_strconcat ("https://", tmp, NULL);
301 				else
302 					url = NULL;
303 
304 				object = jsc_value_new_object (jsc_context, NULL, NULL);
305 
306 				string = jsc_value_new_string (jsc_context, tmp);
307 				jsc_value_object_set_property (object, "text", string);
308 				g_clear_object (&string);
309 
310 				string = jsc_value_new_string (jsc_context, url ? url : tmp);
311 				jsc_value_object_set_property (object, "href", string);
312 				g_clear_object (&string);
313 
314 				add_to_array (object);
315 
316 				g_clear_object (&object);
317 				g_free (tmp);
318 				g_free (url);
319 
320 				text = text + end;
321 				done = FALSE;
322 			}
323 		}
324 	}
325 
326 	if (array && *text) {
327 		JSCValue *object, *string;
328 
329 		object = jsc_value_new_object (jsc_context, NULL, NULL);
330 
331 		string = jsc_value_new_string (jsc_context, text);
332 		jsc_value_object_set_property (object, "text", string);
333 		g_clear_object (&string);
334 
335 		add_to_array (object);
336 
337 		g_clear_object (&object);
338 	}
339 
340 	#undef add_to_array
341 
342 	return array ? array : jsc_value_new_null (jsc_context);
343 }
344 
345 /* Returns 'null' or an object { text : string, imageUri : string, width : nnn, height : nnn }
346    where only the 'text' is required, describing an emoticon. */
347 static JSCValue *
evo_editor_jsc_lookup_emoticon(const gchar * iconName,gboolean use_unicode_smileys,JSCContext * jsc_context)348 evo_editor_jsc_lookup_emoticon (const gchar *iconName,
349 				gboolean use_unicode_smileys,
350 				JSCContext *jsc_context)
351 {
352 	JSCValue *object = NULL;
353 
354 	if (iconName && *iconName) {
355 		const EEmoticon *emoticon;
356 
357 		emoticon = e_emoticon_chooser_lookup_emoticon (iconName);
358 
359 		if (emoticon) {
360 			JSCValue *value;
361 
362 			object = jsc_value_new_object (jsc_context, NULL, NULL);
363 
364 			if (use_unicode_smileys) {
365 				value = jsc_value_new_string (jsc_context, emoticon->unicode_character);
366 				jsc_value_object_set_property (object, "text", value);
367 				g_clear_object (&value);
368 			} else {
369 				gchar *image_uri;
370 
371 				value = jsc_value_new_string (jsc_context, emoticon->text_face);
372 				jsc_value_object_set_property (object, "text", value);
373 				g_clear_object (&value);
374 
375 				image_uri = e_emoticon_get_uri ((EEmoticon *) emoticon);
376 
377 				if (image_uri) {
378 					value = jsc_value_new_string (jsc_context, image_uri);
379 					jsc_value_object_set_property (object, "imageUri", value);
380 					g_clear_object (&value);
381 
382 					value = jsc_value_new_number (jsc_context, 16);
383 					jsc_value_object_set_property (object, "width", value);
384 					g_clear_object (&value);
385 
386 					value = jsc_value_new_number (jsc_context, 16);
387 					jsc_value_object_set_property (object, "height", value);
388 					g_clear_object (&value);
389 
390 					g_free (image_uri);
391 				}
392 			}
393 		}
394 	}
395 
396 	return object ? object : jsc_value_new_null (jsc_context);
397 }
398 
399 static void
evo_editor_jsc_set_spell_check_languages(const gchar * langs,GWeakRef * wkrf_extension)400 evo_editor_jsc_set_spell_check_languages (const gchar *langs,
401 					  GWeakRef *wkrf_extension)
402 {
403 	EEditorWebExtension *extension;
404 	gchar **strv;
405 
406 	g_return_if_fail (wkrf_extension != NULL);
407 
408 	extension = g_weak_ref_get (wkrf_extension);
409 
410 	if (!extension)
411 		return;
412 
413 	if (langs && *langs)
414 		strv = g_strsplit (langs, "|", -1);
415 	else
416 		strv = NULL;
417 
418 	if (!extension->priv->spell_checker)
419 		extension->priv->spell_checker = e_spell_checker_new ();
420 
421 	e_spell_checker_set_active_languages (extension->priv->spell_checker, (const gchar * const *) strv);
422 
423 	g_object_unref (extension);
424 	g_strfreev (strv);
425 }
426 
427 /* Returns whether the 'word' is a properly spelled word. It checks
428    with languages previously set by EvoEditor.SetSpellCheckLanguages(). */
429 static gboolean
evo_editor_jsc_spell_check_word(const gchar * word,GWeakRef * wkrf_extension)430 evo_editor_jsc_spell_check_word (const gchar *word,
431 				 GWeakRef *wkrf_extension)
432 {
433 	EEditorWebExtension *extension;
434 	gboolean is_correct;
435 
436 	g_return_val_if_fail (wkrf_extension != NULL, FALSE);
437 
438 	extension = g_weak_ref_get (wkrf_extension);
439 
440 	if (!extension)
441 		return TRUE;
442 
443 	/* It should be created as part of EvoEditor.SetSpellCheckLanguages(). */
444 	g_warn_if_fail (extension->priv->spell_checker != NULL);
445 
446 	if (!extension->priv->spell_checker)
447 		extension->priv->spell_checker = e_spell_checker_new ();
448 
449 	is_correct = e_spell_checker_check_word (extension->priv->spell_checker, word, -1);
450 
451 	g_object_unref (extension);
452 
453 	return is_correct;
454 }
455 
456 static void
window_object_cleared_cb(WebKitScriptWorld * world,WebKitWebPage * page,WebKitFrame * frame,gpointer user_data)457 window_object_cleared_cb (WebKitScriptWorld *world,
458 			  WebKitWebPage *page,
459 			  WebKitFrame *frame,
460 			  gpointer user_data)
461 {
462 	EEditorWebExtension *extension = user_data;
463 	JSCContext *jsc_context;
464 	JSCValue *jsc_editor;
465 
466 	g_return_if_fail (E_IS_EDITOR_WEB_EXTENSION (extension));
467 
468 	/* Load the javascript files only to the main frame, not to the subframes */
469 	if (!webkit_frame_is_main_frame (frame))
470 		return;
471 
472 	jsc_context = webkit_frame_get_js_context (frame);
473 
474 	/* Read in order approximately as each other uses the previous */
475 	load_javascript_file (jsc_context, "e-convert.js");
476 	load_javascript_file (jsc_context, "e-selection.js");
477 	load_javascript_file (jsc_context, "e-undo-redo.js");
478 	load_javascript_file (jsc_context, "e-editor.js");
479 
480 	jsc_editor = jsc_context_get_value (jsc_context, "EvoEditor");
481 
482 	if (jsc_editor) {
483 		JSCValue *jsc_function;
484 		const gchar *func_name;
485 
486 		/* EvoEditor.splitTextWithLinks(text) */
487 		func_name = "splitTextWithLinks";
488 		jsc_function = jsc_value_new_function (jsc_context, func_name,
489 			G_CALLBACK (evo_editor_jsc_split_text_with_links), g_object_ref (jsc_context), g_object_unref,
490 			JSC_TYPE_VALUE, 1, G_TYPE_STRING);
491 
492 		jsc_value_object_set_property (jsc_editor, func_name, jsc_function);
493 
494 		g_clear_object (&jsc_function);
495 
496 		/* EvoEditor.lookupEmoticon(iconName, useUnicodeSmileys) */
497 		func_name = "lookupEmoticon";
498 		jsc_function = jsc_value_new_function (jsc_context, func_name,
499 			G_CALLBACK (evo_editor_jsc_lookup_emoticon), g_object_ref (jsc_context), g_object_unref,
500 			JSC_TYPE_VALUE, 2, G_TYPE_STRING, G_TYPE_BOOLEAN);
501 
502 		jsc_value_object_set_property (jsc_editor, func_name, jsc_function);
503 
504 		g_clear_object (&jsc_function);
505 
506 		/* EvoEditor.SetSpellCheckLanguages(langs) */
507 		func_name = "SetSpellCheckLanguages";
508 		jsc_function = jsc_value_new_function (jsc_context, func_name,
509 			G_CALLBACK (evo_editor_jsc_set_spell_check_languages), e_weak_ref_new (extension), (GDestroyNotify) e_weak_ref_free,
510 			G_TYPE_NONE, 1, G_TYPE_STRING);
511 
512 		jsc_value_object_set_property (jsc_editor, func_name, jsc_function);
513 
514 		g_clear_object (&jsc_function);
515 
516 		/* EvoEditor.SpellCheckWord(word) */
517 		func_name = "SpellCheckWord";
518 		jsc_function = jsc_value_new_function (jsc_context, func_name,
519 			G_CALLBACK (evo_editor_jsc_spell_check_word), e_weak_ref_new (extension), (GDestroyNotify) e_weak_ref_free,
520 			G_TYPE_BOOLEAN, 1, G_TYPE_STRING);
521 
522 		jsc_value_object_set_property (jsc_editor, func_name, jsc_function);
523 
524 		g_clear_object (&jsc_function);
525 		g_clear_object (&jsc_editor);
526 	}
527 
528 	g_clear_object (&jsc_context);
529 }
530 
531 static gboolean
web_page_send_request_cb(WebKitWebPage * web_page,WebKitURIRequest * request,WebKitURIResponse * redirected_response,EEditorWebExtension * extension)532 web_page_send_request_cb (WebKitWebPage *web_page,
533 			  WebKitURIRequest *request,
534 			  WebKitURIResponse *redirected_response,
535 			  EEditorWebExtension *extension)
536 {
537 	const gchar *request_uri;
538 	const gchar *page_uri;
539 
540 	request_uri = webkit_uri_request_get_uri (request);
541 	page_uri = webkit_web_page_get_uri (web_page);
542 
543 	/* Always load the main resource. */
544 	if (g_strcmp0 (request_uri, page_uri) == 0)
545 		return FALSE;
546 
547 	if (g_str_has_prefix (request_uri, "http:") ||
548 	    g_str_has_prefix (request_uri, "https:")) {
549 		gchar *new_uri;
550 
551 		new_uri = g_strconcat ("evo-", request_uri, NULL);
552 
553 		webkit_uri_request_set_uri (request, new_uri);
554 
555 		g_free (new_uri);
556 	}
557 
558 	return FALSE;
559 }
560 
561 static void
web_page_document_loaded_cb(WebKitWebPage * web_page,gpointer user_data)562 web_page_document_loaded_cb (WebKitWebPage *web_page,
563 			     gpointer user_data)
564 {
565 	g_return_if_fail (WEBKIT_IS_WEB_PAGE (web_page));
566 
567 	window_object_cleared_cb (NULL, web_page, webkit_web_page_get_main_frame (web_page), user_data);
568 }
569 
570 static void
web_page_created_cb(WebKitWebExtension * wk_extension,WebKitWebPage * web_page,EEditorWebExtension * extension)571 web_page_created_cb (WebKitWebExtension *wk_extension,
572 		     WebKitWebPage *web_page,
573 		     EEditorWebExtension *extension)
574 {
575 	g_return_if_fail (WEBKIT_IS_WEB_PAGE (web_page));
576 	g_return_if_fail (E_IS_EDITOR_WEB_EXTENSION (extension));
577 
578 	window_object_cleared_cb (NULL, web_page, webkit_web_page_get_main_frame (web_page), extension);
579 
580 	g_signal_connect (
581 		web_page, "send-request",
582 		G_CALLBACK (web_page_send_request_cb), extension);
583 
584 	g_signal_connect (
585 		web_page, "document-loaded",
586 		G_CALLBACK (web_page_document_loaded_cb), extension);
587 }
588 
589 void
e_editor_web_extension_initialize(EEditorWebExtension * extension,WebKitWebExtension * wk_extension)590 e_editor_web_extension_initialize (EEditorWebExtension *extension,
591 				   WebKitWebExtension *wk_extension)
592 {
593 	WebKitScriptWorld *script_world;
594 
595 	g_return_if_fail (E_IS_EDITOR_WEB_EXTENSION (extension));
596 
597 	extension->priv->wk_extension = g_object_ref (wk_extension);
598 
599 	g_signal_connect (
600 		wk_extension, "page-created",
601 		G_CALLBACK (web_page_created_cb), extension);
602 
603 	script_world = webkit_script_world_get_default ();
604 
605 	g_signal_connect (script_world, "window-object-cleared",
606 		G_CALLBACK (window_object_cleared_cb), extension);
607 }
608