1/* 2* Copyright (c) 2009-2013 Yorba Foundation 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.1 of the License, or (at your option) any later version. 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* General Public License for more details. 13* 14* You should have received a copy of the GNU General Public 15* License along with this program; if not, write to the 16* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, 17* Boston, MA 02110-1301 USA 18*/ 19 20extern Soup.Message soup_form_request_new_from_multipart (string uri, Soup.Multipart multipart); 21 22namespace Publishing.RESTSupport { 23 24public abstract class Session { 25 private string? endpoint_url = null; 26 private Soup.Session soup_session = null; 27 private bool transactions_stopped = false; 28 29 public signal void wire_message_unqueued (Soup.Message message); 30 public signal void authenticated (); 31 public signal void authentication_failed (Spit.Publishing.PublishingError err); 32 33 protected Session (string? endpoint_url = null) { 34 this.endpoint_url = endpoint_url; 35 soup_session = new Soup.Session (); 36 } 37 38 protected void notify_wire_message_unqueued (Soup.Message message) { 39 wire_message_unqueued (message); 40 } 41 42 protected void notify_authenticated () { 43 authenticated (); 44 } 45 46 protected void notify_authentication_failed (Spit.Publishing.PublishingError err) { 47 authentication_failed (err); 48 } 49 50 public abstract bool is_authenticated (); 51 52 public string? get_endpoint_url () { 53 return endpoint_url; 54 } 55 56 public void stop_transactions () { 57 transactions_stopped = true; 58 soup_session.abort (); 59 } 60 61 public bool are_transactions_stopped () { 62 return transactions_stopped; 63 } 64 65 public void send_wire_message (Soup.Message message) { 66 if (are_transactions_stopped ()) 67 return; 68 69 soup_session.request_unqueued.connect (notify_wire_message_unqueued); 70 soup_session.send_message (message); 71 72 soup_session.request_unqueued.disconnect (notify_wire_message_unqueued); 73 } 74} 75 76public enum HttpMethod { 77 GET, 78 POST, 79 PUT; 80 81 public string to_string () { 82 switch (this) { 83 case HttpMethod.GET: 84 return "GET"; 85 86 case HttpMethod.PUT: 87 return "PUT"; 88 89 case HttpMethod.POST: 90 return "POST"; 91 92 default: 93 error ("unrecognized HTTP method enumeration value"); 94 } 95 } 96 97 public static HttpMethod from_string (string str) { 98 if (str == "GET") { 99 return HttpMethod.GET; 100 } else if (str == "PUT") { 101 return HttpMethod.PUT; 102 } else if (str == "POST") { 103 return HttpMethod.POST; 104 } else { 105 error ("unrecognized HTTP method name: %s", str); 106 } 107 } 108} 109 110public class Argument { 111 public string key; 112 public string value; 113 114 public Argument (string key, string value) { 115 this.key = key; 116 this.value = value; 117 } 118 119 public static int compare (Argument arg1, Argument arg2) { 120 return strcmp (arg1.key, arg2.key); 121 } 122 123 public static Argument[] sort (Argument[] input_array) { 124 FixedTreeSet<Argument> sorted_args = new FixedTreeSet<Argument> (Argument.compare); 125 126 foreach (Argument arg in input_array) 127 sorted_args.add (arg); 128 129 return sorted_args.to_array (); 130 } 131} 132 133public class Transaction { 134 private Argument[] arguments; 135 private bool is_executed = false; 136 private weak Session parent_session = null; 137 private Soup.Message message = null; 138 private int bytes_written = 0; 139 private Spit.Publishing.PublishingError? err = null; 140 private string? endpoint_url = null; 141 private bool use_custom_payload; 142 143 public signal void chunk_transmitted (int bytes_written_so_far, int total_bytes); 144 public signal void network_error (Spit.Publishing.PublishingError err); 145 public signal void completed (); 146 147 public Transaction (Session parent_session, HttpMethod method = HttpMethod.POST) { 148 // if our creator doesn't specify an endpoint url by using the Transaction.with_endpoint_url 149 // constructor, then our parent session must have a non-null endpoint url 150 assert (parent_session.get_endpoint_url () != null); 151 152 this.parent_session = parent_session; 153 154 message = new Soup.Message (method.to_string (), parent_session.get_endpoint_url ()); 155 message.wrote_body_data.connect (on_wrote_body_data); 156 } 157 158 public Transaction.with_endpoint_url (Session parent_session, string endpoint_url, 159 HttpMethod method = HttpMethod.POST) { 160 this.parent_session = parent_session; 161 this.endpoint_url = endpoint_url; 162 message = new Soup.Message (method.to_string (), endpoint_url); 163 } 164 165 private void on_wrote_body_data (Soup.Buffer written_data) { 166 bytes_written += (int) written_data.length; 167 chunk_transmitted (bytes_written, (int) message.request_body.length); 168 } 169 170 private void on_message_unqueued (Soup.Message message) { 171 if (this.message != message) 172 return; 173 174 try { 175 check_response (message); 176 } catch (Spit.Publishing.PublishingError err) { 177 warning ("Publishing error: %s", err.message); 178 warning ("response validation failed. bad response = '%s'.", get_response ()); 179 this.err = err; 180 } 181 } 182 183 protected void check_response (Soup.Message message) throws Spit.Publishing.PublishingError { 184 switch (message.status_code) { 185 case Soup.Status.OK: 186 case Soup.Status.CREATED: // HTTP code 201 (CREATED) signals that a new 187 // resource was created in response to a PUT or POST 188 break; 189 190 case Soup.Status.CANT_RESOLVE: 191 case Soup.Status.CANT_RESOLVE_PROXY: 192 throw new Spit.Publishing.PublishingError.NO_ANSWER ("Unable to resolve %s (error code %u)", 193 get_endpoint_url (), message.status_code); 194 195 case Soup.Status.CANT_CONNECT: 196 case Soup.Status.CANT_CONNECT_PROXY: 197 throw new Spit.Publishing.PublishingError.NO_ANSWER ("Unable to connect to %s (error code %u)", 198 get_endpoint_url (), message.status_code); 199 200 default: 201 // status codes below 100 are used by Soup, 100 and above are defined HTTP codes 202 if (message.status_code >= 100) { 203 throw new Spit.Publishing.PublishingError.NO_ANSWER ("Service %s returned HTTP status code %u %s", 204 get_endpoint_url (), message.status_code, message.reason_phrase); 205 } else { 206 throw new Spit.Publishing.PublishingError.NO_ANSWER ("Failure communicating with %s (error code %u)", 207 get_endpoint_url (), message.status_code); 208 } 209 } 210 211 // All valid communication involves body data in the response 212 if (message.response_body.data == null || message.response_body.data.length == 0) 213 throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("No response data from %s", 214 get_endpoint_url ()); 215 } 216 217 public Argument[] get_arguments () { 218 return arguments; 219 } 220 221 public Argument[] get_sorted_arguments () { 222 return Argument.sort (get_arguments ()); 223 } 224 225 protected void set_is_executed (bool is_executed) { 226 this.is_executed = is_executed; 227 } 228 229 protected void send () throws Spit.Publishing.PublishingError { 230 parent_session.wire_message_unqueued.connect (on_message_unqueued); 231 message.wrote_body_data.connect (on_wrote_body_data); 232 parent_session.send_wire_message (message); 233 234 parent_session.wire_message_unqueued.disconnect (on_message_unqueued); 235 message.wrote_body_data.disconnect (on_wrote_body_data); 236 237 if (err != null) 238 network_error (err); 239 else 240 completed (); 241 242 if (err != null) 243 throw err; 244 } 245 246 public HttpMethod get_method () { 247 return HttpMethod.from_string (message.method); 248 } 249 250 protected virtual void add_header (string key, string value) { 251 message.request_headers.append (key, value); 252 } 253 254 // set custom_payload to null to have this transaction send the default payload of 255 // key-value pairs appended through add_argument(...) (this is how most REST requests work). 256 // To send a payload other than traditional key-value pairs (such as an XML document or a JPEG 257 // image) to the endpoint, set the custom_payload parameter to a non-null value. If the 258 // custom_payload you specify is text data, then it's null terminated, and its length is just 259 // custom_payload.length, so you don't have to pass in a payload_length parameter in this case. 260 // If, however, custom_payload is binary data (such as a JEPG), then the caller must set 261 // payload_length to the byte length of the custom_payload buffer 262 protected void set_custom_payload (string? custom_payload, string payload_content_type, 263 ulong payload_length = 0) { 264 assert (get_method () != HttpMethod.GET); // GET messages don't have payloads 265 266 if (custom_payload == null) { 267 use_custom_payload = false; 268 return; 269 } 270 271 ulong length = (payload_length > 0) ? payload_length : custom_payload.length; 272 message.set_request (payload_content_type, Soup.MemoryUse.COPY, custom_payload.data[0:length]); 273 274 use_custom_payload = true; 275 } 276 277 // When writing a specialized transaction subclass you should rarely need to 278 // call this method. In general, it's better to leave the underlying Soup message 279 // alone and let the Transaction class manage it for you. You should only need 280 // to install a new message if your subclass has radically different behavior from 281 // normal Transactions -- like multipart encoding. 282 protected void set_message (Soup.Message message) { 283 this.message = message; 284 } 285 286 public bool get_is_executed () { 287 return is_executed; 288 } 289 290 public uint get_status_code () { 291 assert (get_is_executed ()); 292 return message.status_code; 293 } 294 295 public virtual void execute () throws Spit.Publishing.PublishingError { 296 // if a custom payload is being used, we don't need to peform the tasks that are necessary 297 // to prepare a traditional key-value pair REST request; Instead (since we don't 298 // know anything about the custom payload), we just put it on the wire and return 299 if (use_custom_payload) { 300 is_executed = true; 301 send (); 302 303 return; 304 } 305 306 // REST POST requests must transmit at least one argument 307 if (get_method () == HttpMethod.POST) 308 assert (arguments.length > 0); 309 310 // concatenate the REST arguments array into an HTTP formdata string 311 string formdata_string = ""; 312 for (int i = 0; i < arguments.length; i++) { 313 formdata_string += ("%s=%s".printf (arguments[i].key, arguments[i].value)); 314 if (i < arguments.length - 1) 315 formdata_string += "&"; 316 } 317 318 // for GET requests with arguments, append the formdata string to the endpoint url after a 319 // query divider ('?') -- but make sure to save the old (caller-specified) endpoint URL 320 // and restore it after the GET so that the underlying Soup message remains consistent 321 string old_url = null; 322 string url_with_query = null; 323 if (get_method () == HttpMethod.GET && arguments.length > 0) { 324 old_url = message.get_uri ().to_string (false); 325 url_with_query = get_endpoint_url () + "?" + formdata_string; 326 message.set_uri (new Soup.URI (url_with_query)); 327 } else { 328 message.set_request ("application/x-www-form-urlencoded", Soup.MemoryUse.COPY, 329 formdata_string.data); 330 } 331 332 is_executed = true; 333 334 try { 335 debug ("sending message to URI = '%s'", message.get_uri ().to_string (false)); 336 send (); 337 } finally { 338 // if old_url is non-null, then restore it 339 if (old_url != null) 340 message.set_uri (new Soup.URI (old_url)); 341 } 342 } 343 344 public string get_response () { 345 assert (get_is_executed ()); 346 return (string) message.response_body.data; 347 } 348 349 public unowned Soup.MessageHeaders get_response_headers () { 350 assert (get_is_executed ()); 351 return message.response_headers; 352 } 353 354 public void add_argument (string name, string value) { 355 arguments += new Argument (name, value); 356 } 357 358 public string? get_endpoint_url () { 359 return (endpoint_url != null) ? endpoint_url : parent_session.get_endpoint_url (); 360 } 361 362 public Session get_parent_session () { 363 return parent_session; 364 } 365} 366 367public class UploadTransaction : Transaction { 368 protected GLib.HashTable<string, string> binary_disposition_table = null; 369 protected Spit.Publishing.Publishable publishable = null; 370 protected string mime_type; 371 protected Gee.HashMap<string, string> message_headers = null; 372 373 public UploadTransaction (Session session, Spit.Publishing.Publishable publishable) { 374 base (session); 375 this.publishable = publishable; 376 this.mime_type = media_type_to_mime_type (publishable.get_media_type ()); 377 378 binary_disposition_table = create_default_binary_disposition_table (); 379 380 message_headers = new Gee.HashMap<string, string> (); 381 } 382 383 public UploadTransaction.with_endpoint_url (Session session, 384 Spit.Publishing.Publishable publishable, string endpoint_url) { 385 base.with_endpoint_url (session, endpoint_url); 386 this.publishable = publishable; 387 this.mime_type = media_type_to_mime_type (publishable.get_media_type ()); 388 389 binary_disposition_table = create_default_binary_disposition_table (); 390 391 message_headers = new Gee.HashMap<string, string> (); 392 } 393 394 protected override void add_header (string key, string value) { 395 message_headers.set (key, value); 396 } 397 398 private static string media_type_to_mime_type (Spit.Publishing.Publisher.MediaType media_type) { 399 if (media_type == Spit.Publishing.Publisher.MediaType.PHOTO) 400 return "image/jpeg"; 401 else if (media_type == Spit.Publishing.Publisher.MediaType.VIDEO) 402 return "video/mpeg"; 403 else 404 error ("UploadTransaction: unknown media type %s.", media_type.to_string ()); 405 } 406 407 private GLib.HashTable<string, string> create_default_binary_disposition_table () { 408 GLib.HashTable<string, string> result = 409 new GLib.HashTable<string, string> (GLib.str_hash, GLib.str_equal); 410 411 result.insert ("filename", Soup.URI.encode (publishable.get_serialized_file ().get_basename (), 412 null)); 413 414 return result; 415 } 416 417 protected void set_binary_disposition_table (GLib.HashTable<string, string> new_disp_table) { 418 binary_disposition_table = new_disp_table; 419 } 420 421 public override void execute () throws Spit.Publishing.PublishingError { 422 Argument[] request_arguments = get_arguments (); 423 assert (request_arguments.length > 0); 424 425 Soup.Multipart message_parts = new Soup.Multipart ("multipart/form-data"); 426 427 foreach (Argument arg in request_arguments) 428 message_parts.append_form_string (arg.key, arg.value); 429 430 string payload; 431 size_t payload_length; 432 try { 433 FileUtils.get_contents (publishable.get_serialized_file ().get_path (), out payload, 434 out payload_length); 435 } catch (FileError e) { 436 throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR ( 437 _ ("A temporary file needed for publishing is unavailable")); 438 } 439 440 int payload_part_num = message_parts.get_length (); 441 442 Soup.Buffer bindable_data = new Soup.Buffer (Soup.MemoryUse.COPY, payload.data[0:payload_length]); 443 message_parts.append_form_file ("", publishable.get_serialized_file ().get_path (), mime_type, 444 bindable_data); 445 446 unowned Soup.MessageHeaders image_part_header; 447 unowned Soup.Buffer image_part_body; 448 message_parts.get_part (payload_part_num, out image_part_header, out image_part_body); 449 image_part_header.set_content_disposition ("form-data", binary_disposition_table); 450 451 Soup.Message outbound_message = 452 soup_form_request_new_from_multipart (get_endpoint_url (), message_parts); 453 // TODO: there must be a better way to iterate over a map 454 Gee.MapIterator<string, string> i = message_headers.map_iterator (); 455 bool cont = i.next (); 456 while (cont) { 457 outbound_message.request_headers.append (i.get_key (), i.get_value ()); 458 cont = i.next (); 459 } 460 set_message (outbound_message); 461 462 set_is_executed (true); 463 send (); 464 } 465} 466 467public class XmlDocument { 468 // Returns non-null string if an error condition is discovered in the XML (such as a well-known 469 // node). The string is used when generating a PublishingError exception. This delegate does 470 // not need to check for general-case malformed XML. 471 public delegate string? CheckForErrorResponse (XmlDocument doc); 472 473 private Xml.Doc *document; 474 475 private XmlDocument (Xml.Doc *doc) { 476 document = doc; 477 } 478 479 ~XmlDocument () { 480 delete document; 481 } 482 483 public Xml.Node *get_root_node () { 484 return document->get_root_element (); 485 } 486 487 public Xml.Node *get_named_child (Xml.Node *parent, string child_name) 488 throws Spit.Publishing.PublishingError { 489 Xml.Node *doc_node_iter = parent->children; 490 491 for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) { 492 if (doc_node_iter->name == child_name) 493 return doc_node_iter; 494 } 495 496 throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Can't find XML node %s", 497 child_name); 498 } 499 500 public string get_property_value (Xml.Node *node, string property_key) 501 throws Spit.Publishing.PublishingError { 502 string value_string = node->get_prop (property_key); 503 if (value_string == null) 504 throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Can't find XML " + 505 "property %s on node %s", property_key, node->name); 506 507 return value_string; 508 } 509 510 public static XmlDocument parse_string (string? input_string, 511 CheckForErrorResponse check_for_error_response) throws Spit.Publishing.PublishingError { 512 if (input_string == null || input_string.length == 0) 513 throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Empty XML string"); 514 515 // Does this even start and end with the right characters? 516 if (!input_string.chug ().chomp ().has_prefix ("<") || 517 !input_string.chug ().chomp ().has_suffix (">")) { 518 // Didn't start or end with a < or > and can't be parsed as XML - treat as malformed. 519 throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Unable to parse XML " + 520 "document"); 521 } 522 523 // Don't want blanks to be included as text nodes, and want the XML parser to tolerate 524 // tolerable XML 525 Xml.Doc *doc = Xml.Parser.read_memory (input_string, (int) input_string.length, null, null, 526 Xml.ParserOption.NOBLANKS | Xml.ParserOption.RECOVER); 527 if (doc == null) 528 throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Unable to parse XML " + 529 "document"); 530 531 // Since 'doc' is the top level, if it has no children, something is wrong 532 // with the XML; we cannot continue normally here. 533 if (doc->children == null) { 534 throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Unable to parse XML " + 535 "document"); 536 } 537 538 XmlDocument rest_doc = new XmlDocument (doc); 539 540 string? result = check_for_error_response (rest_doc); 541 if (result != null) 542 throw new Spit.Publishing.PublishingError.SERVICE_ERROR ("%s", result); 543 544 return rest_doc; 545 } 546} 547 548/* Encoding strings in XML decimal encoding is a relatively esoteric operation. Most web services 549 prefer to have non-ASCII character entities encoded using "symbolic encoding," where common 550 entities are encoded in short, symbolic names (e.g. "ñ" -> ñ). Picasa Web Albums, 551 however, doesn't like symbolic encoding, and instead wants non-ASCII entities encoded directly 552 as their Unicode code point numbers (e.g. "ñ" -> &241;). */ 553public string decimal_entity_encode (string source) { 554 StringBuilder encoded_str_builder = new StringBuilder (); 555 string current_char = source; 556 while (true) { 557 int current_char_value = (int) (current_char.get_char_validated ()); 558 559 // null character signals end of string 560 if (current_char_value < 1) 561 break; 562 563 // no need to escape ASCII characters except the ampersand, greater-than sign and less-than 564 // signs, which are special in the world of XML 565 if ((current_char_value < 128) && (current_char_value != '&') && (current_char_value != '<') && 566 (current_char_value != '>')) 567 encoded_str_builder.append_unichar (current_char.get_char_validated ()); 568 else 569 encoded_str_builder.append ("&#%d;".printf (current_char_value)); 570 571 current_char = current_char.next_char (); 572 } 573 574 return encoded_str_builder.str; 575} 576 577internal abstract class BatchUploader { 578 private int current_file = 0; 579 private Spit.Publishing.Publishable[] publishables = null; 580 private Session session = null; 581 private unowned Spit.Publishing.ProgressCallback? status_updated = null; 582 583 public signal void upload_complete (int num_photos_published); 584 public signal void upload_error (Spit.Publishing.PublishingError err); 585 586 protected BatchUploader (Session session, Spit.Publishing.Publishable[] publishables) { 587 this.publishables = publishables; 588 this.session = session; 589 } 590 591 private void send_files () { 592 current_file = 0; 593 bool stop = false; 594 foreach (Spit.Publishing.Publishable publishable in publishables) { 595 GLib.File? file = publishable.get_serialized_file (); 596 597 // if the current publishable hasn't been serialized, then skip it 598 if (file == null) { 599 current_file++; 600 continue; 601 } 602 603 double fraction_complete = ((double) current_file) / publishables.length; 604 if (status_updated != null) 605 status_updated (current_file + 1, fraction_complete); 606 607 Transaction txn = create_transaction (publishables[current_file]); 608 609 txn.chunk_transmitted.connect (on_chunk_transmitted); 610 611 try { 612 txn.execute (); 613 } catch (Spit.Publishing.PublishingError err) { 614 upload_error (err); 615 stop = true; 616 } 617 618 txn.chunk_transmitted.disconnect (on_chunk_transmitted); 619 620 if (stop) 621 break; 622 623 current_file++; 624 } 625 626 if (!stop) 627 upload_complete (current_file); 628 } 629 630 private void on_chunk_transmitted (int bytes_written_so_far, int total_bytes) { 631 double file_span = 1.0 / publishables.length; 632 double this_file_fraction_complete = ((double) bytes_written_so_far) / total_bytes; 633 double fraction_complete = (current_file * file_span) + (this_file_fraction_complete * 634 file_span); 635 636 if (status_updated != null) 637 status_updated (current_file + 1, fraction_complete); 638 } 639 640 protected Session get_session () { 641 return session; 642 } 643 644 protected Spit.Publishing.Publishable get_current_publishable () { 645 return publishables[current_file]; 646 } 647 648 protected abstract Transaction create_transaction (Spit.Publishing.Publishable publishable); 649 650 public void upload (Spit.Publishing.ProgressCallback? status_updated = null) { 651 this.status_updated = status_updated; 652 653 if (publishables.length > 0) 654 send_files (); 655 } 656} 657 658// Remove diacritics in a string, yielding ASCII. If the given string is in 659// a character set not based on Latin letters (e.g. Cyrillic), the result 660// may be empty. 661public string asciify_string (string s) { 662 string t = s.normalize (); // default normalization yields a maximally decomposed form 663 664 StringBuilder b = new StringBuilder (); 665 for (unowned string u = t; u.get_char () != 0 ; u = u.next_char ()) { 666 unichar c = u.get_char (); 667 if ((int) c < 128) 668 b.append_unichar (c); 669 } 670 671 return b.str; 672} 673 674/** @brief Work-around for a problem in libgee where a TreeSet can leak references when it 675 * goes out of scope; please see https://bugzilla.gnome.org/show_bug.cgi?id=695045 for more 676 * details. This class merely wraps it and adds a call to clear () to the destructor. 677 */ 678public class FixedTreeSet<G> : Gee.TreeSet<G> { 679 public FixedTreeSet (owned CompareDataFunc<G>? comp_func = null) { 680 base ((owned) comp_func); 681 } 682 683 ~FixedTreeSet () { 684 clear (); 685 } 686} 687 688public abstract class GoogleSession : Session { 689 public abstract string get_user_name (); 690 public abstract string get_access_token (); 691 public abstract string get_refresh_token (); 692 public abstract void deauthenticate (); 693} 694 695public abstract class GooglePublisher : Object, Spit.Publishing.Publisher { 696 private const string OAUTH_CLIENT_ID = "1073902228337-gm4uf5etk25s0hnnm0g7uv2tm2bm1j0b.apps.googleusercontent.com"; 697 private const string OAUTH_CLIENT_SECRET = "_kA4RZz72xqed4DqfO7xMmMN"; 698 699 private class GoogleSessionImpl : GoogleSession { 700 public string? access_token; 701 public string? user_name; 702 public string? refresh_token; 703 704 public GoogleSessionImpl () { 705 this.access_token = null; 706 this.user_name = null; 707 this.refresh_token = null; 708 } 709 710 public override bool is_authenticated () { 711 return (access_token != null); 712 } 713 714 public override string get_user_name () { 715 assert (user_name != null); 716 return user_name; 717 } 718 719 public override string get_access_token () { 720 assert (is_authenticated ()); 721 return access_token; 722 } 723 724 public override string get_refresh_token () { 725 assert (refresh_token != null); 726 return refresh_token; 727 } 728 729 public override void deauthenticate () { 730 access_token = null; 731 user_name = null; 732 refresh_token = null; 733 } 734 } 735 736 private class WebAuthenticationPane : Spit.Publishing.DialogPane, Object { 737 public static bool cache_dirty = false; 738 739 private WebKit.WebView webview; 740 private Gtk.Box pane_widget; 741 private Gtk.ScrolledWindow webview_frame; 742 private string auth_sequence_start_url; 743 744 public signal void authorized (string auth_code); 745 746 public WebAuthenticationPane (string auth_sequence_start_url) { 747 this.auth_sequence_start_url = auth_sequence_start_url; 748 749 pane_widget = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); 750 751 webview_frame = new Gtk.ScrolledWindow (null, null); 752 webview_frame.set_shadow_type (Gtk.ShadowType.ETCHED_IN); 753 webview_frame.set_policy (Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); 754 webview_frame.expand = true; 755 756 webview = new WebKit.WebView (); 757 webview.get_settings ().enable_plugins = false; 758 webview.load_changed.connect ((load_event) => { 759 if (load_event == WebKit.LoadEvent.STARTED) { 760 on_load_started (); 761 } else if (load_event == WebKit.LoadEvent.FINISHED) { 762 on_page_load (); 763 } 764 }); 765 766 webview_frame.add (webview); 767 pane_widget.pack_start (webview_frame, true, true, 0); 768 } 769 770 public static bool is_cache_dirty () { 771 return cache_dirty; 772 } 773 774 private void on_page_load () { 775 pane_widget.get_window ().set_cursor (new Gdk.Cursor.for_display (Gdk.Display.get_default (), Gdk.CursorType.LEFT_PTR)); 776 777 string page_title = webview.get_title (); 778 if (page_title.index_of ("state=connect") > 0) { 779 int auth_code_field_start = page_title.index_of ("code="); 780 if (auth_code_field_start < 0) 781 return; 782 783 string auth_code = 784 page_title.substring (auth_code_field_start + 5); // 5 = "code=".length 785 786 cache_dirty = true; 787 788 authorized (auth_code); 789 } 790 } 791 792 private void on_load_started () { 793 pane_widget.get_window ().set_cursor (new Gdk.Cursor.for_display (Gdk.Display.get_default (), Gdk.CursorType.WATCH)); 794 } 795 796 public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry () { 797 return Spit.Publishing.DialogPane.GeometryOptions.NONE; 798 } 799 800 public Gtk.Widget get_widget () { 801 return pane_widget; 802 } 803 804 public void on_pane_installed () { 805 webview.load_uri (auth_sequence_start_url); 806 } 807 808 public void on_pane_uninstalled () { 809 } 810 } 811 812 private class GetAccessTokensTransaction : Publishing.RESTSupport.Transaction { 813 private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token"; 814 815 public GetAccessTokensTransaction (Session session, string auth_code) { 816 base.with_endpoint_url (session, ENDPOINT_URL); 817 818 add_argument ("code", auth_code); 819 add_argument ("client_id", OAUTH_CLIENT_ID); 820 add_argument ("client_secret", OAUTH_CLIENT_SECRET); 821 add_argument ("redirect_uri", "urn:ietf:wg:oauth:2.0:oob"); 822 add_argument ("grant_type", "authorization_code"); 823 } 824 } 825 826 private class RefreshAccessTokenTransaction : Publishing.RESTSupport.Transaction { 827 private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token"; 828 829 public RefreshAccessTokenTransaction (Session session) { 830 base.with_endpoint_url (session, ENDPOINT_URL); 831 832 add_argument ("client_id", OAUTH_CLIENT_ID); 833 add_argument ("client_secret", OAUTH_CLIENT_SECRET); 834 add_argument ("refresh_token", ((GoogleSession) session).get_refresh_token ()); 835 add_argument ("grant_type", "refresh_token"); 836 } 837 } 838 839 public class AuthenticatedTransaction : Publishing.RESTSupport.Transaction { 840 private AuthenticatedTransaction.with_endpoint_url (GoogleSession session, 841 string endpoint_url, Publishing.RESTSupport.HttpMethod method) { 842 base.with_endpoint_url (session, endpoint_url, method); 843 } 844 845 public AuthenticatedTransaction (GoogleSession session, string endpoint_url, 846 Publishing.RESTSupport.HttpMethod method) { 847 base.with_endpoint_url (session, endpoint_url, method); 848 assert (session.is_authenticated ()); 849 850 add_header ("Authorization", "Bearer " + session.get_access_token ()); 851 } 852 } 853 854 private class UsernameFetchTransaction : AuthenticatedTransaction { 855 private const string ENDPOINT_URL = "https://www.googleapis.com/oauth2/v1/userinfo"; 856 857 public UsernameFetchTransaction (GoogleSession session) { 858 base (session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET); 859 } 860 } 861 862 private string scope; 863 private GoogleSessionImpl session; 864 private WebAuthenticationPane? web_auth_pane; 865 private weak Spit.Publishing.PluginHost host; 866 private weak Spit.Publishing.Service service; 867 868 protected GooglePublisher (Spit.Publishing.Service service, Spit.Publishing.PluginHost host, 869 string scope) { 870 this.scope = scope; 871 this.session = new GoogleSessionImpl (); 872 this.service = service; 873 this.host = host; 874 this.web_auth_pane = null; 875 } 876 877 private void on_web_auth_pane_authorized (string auth_code) { 878 web_auth_pane.authorized.disconnect (on_web_auth_pane_authorized); 879 880 debug ("EVENT: user authorized scope %s with auth_code %s", scope, auth_code); 881 882 if (!is_running ()) 883 return; 884 885 do_get_access_tokens (auth_code); 886 } 887 888 private void on_get_access_tokens_complete (Publishing.RESTSupport.Transaction txn) { 889 txn.completed.disconnect (on_get_access_tokens_complete); 890 txn.network_error.disconnect (on_get_access_tokens_error); 891 892 debug ("EVENT: network transaction to exchange authorization code for access tokens " + 893 "completed successfully."); 894 895 if (!is_running ()) 896 return; 897 898 do_extract_tokens (txn.get_response ()); 899 } 900 901 private void on_get_access_tokens_error (Publishing.RESTSupport.Transaction txn, 902 Spit.Publishing.PublishingError err) { 903 txn.completed.disconnect (on_get_access_tokens_complete); 904 txn.network_error.disconnect (on_get_access_tokens_error); 905 906 debug ("EVENT: network transaction to exchange authorization code for access tokens " + 907 "failed; response = '%s'", txn.get_response ()); 908 909 if (!is_running ()) 910 return; 911 912 host.post_error (err); 913 } 914 915 private void on_refresh_access_token_transaction_completed (Publishing.RESTSupport.Transaction 916 txn) { 917 txn.completed.disconnect (on_refresh_access_token_transaction_completed); 918 txn.network_error.disconnect (on_refresh_access_token_transaction_error); 919 920 debug ("EVENT: refresh access token transaction completed successfully."); 921 922 if (!is_running ()) 923 return; 924 925 if (session.is_authenticated ()) // ignore these events if the session is already auth'd 926 return; 927 928 do_extract_tokens (txn.get_response ()); 929 } 930 931 private void on_refresh_access_token_transaction_error (Publishing.RESTSupport.Transaction txn, 932 Spit.Publishing.PublishingError err) { 933 txn.completed.disconnect (on_refresh_access_token_transaction_completed); 934 txn.network_error.disconnect (on_refresh_access_token_transaction_error); 935 936 debug ("EVENT: refresh access token transaction caused a network error."); 937 938 if (!is_running ()) 939 return; 940 941 if (session.is_authenticated ()) // ignore these events if the session is already auth'd 942 return; 943 944 // 400 errors indicate that the OAuth client ID and secret have become invalid. In most 945 // cases, this can be fixed by logging the user out 946 if (txn.get_status_code () == 400) { 947 do_logout (); 948 return; 949 } 950 951 host.post_error (err); 952 } 953 954 private void on_refresh_token_available (string token) { 955 debug ("EVENT: an OAuth refresh token has become available; token = '%s'.", token); 956 957 if (!is_running ()) 958 return; 959 960 session.refresh_token = token; 961 } 962 963 private void on_access_token_available (string token) { 964 debug ("EVENT: an OAuth access token has become available; token = '%s'.", token); 965 966 if (!is_running ()) 967 return; 968 969 session.access_token = token; 970 971 do_fetch_username (); 972 } 973 974 private void on_fetch_username_transaction_completed (Publishing.RESTSupport.Transaction txn) { 975 txn.completed.disconnect (on_fetch_username_transaction_completed); 976 txn.network_error.disconnect (on_fetch_username_transaction_error); 977 978 debug ("EVENT: username fetch transaction completed successfully."); 979 980 if (!is_running ()) 981 return; 982 983 do_extract_username (txn.get_response ()); 984 } 985 986 private void on_fetch_username_transaction_error (Publishing.RESTSupport.Transaction txn, 987 Spit.Publishing.PublishingError err) { 988 txn.completed.disconnect (on_fetch_username_transaction_completed); 989 txn.network_error.disconnect (on_fetch_username_transaction_error); 990 991 debug ("EVENT: username fetch transaction caused a network error"); 992 993 if (!is_running ()) 994 return; 995 996 host.post_error (err); 997 } 998 999 private void do_get_access_tokens (string auth_code) { 1000 debug ("ACTION: exchanging authorization code for access & refresh tokens"); 1001 1002 host.install_login_wait_pane (); 1003 1004 GetAccessTokensTransaction tokens_txn = new GetAccessTokensTransaction (session, auth_code); 1005 tokens_txn.completed.connect (on_get_access_tokens_complete); 1006 tokens_txn.network_error.connect (on_get_access_tokens_error); 1007 1008 try { 1009 tokens_txn.execute (); 1010 } catch (Spit.Publishing.PublishingError err) { 1011 host.post_error (err); 1012 } 1013 } 1014 1015 private void do_hosted_web_authentication () { 1016 debug ("ACTION: running OAuth authentication flow in hosted web pane."); 1017 1018 string user_authorization_url = "https://accounts.google.com/o/oauth2/auth?" + 1019 "response_type=code&" + 1020 "client_id=" + OAUTH_CLIENT_ID + "&" + 1021 "redirect_uri=" + Soup.URI.encode ("urn:ietf:wg:oauth:2.0:oob", null) + "&" + 1022 "scope=" + Soup.URI.encode (scope, null) + "+" + 1023 Soup.URI.encode ("https://www.googleapis.com/auth/userinfo.profile", null) + "&" + 1024 "state=connect&" + 1025 "access_type=offline&" + 1026 "approval_prompt=force"; 1027 1028 web_auth_pane = new WebAuthenticationPane (user_authorization_url); 1029 web_auth_pane.authorized.connect (on_web_auth_pane_authorized); 1030 1031 host.install_dialog_pane (web_auth_pane); 1032 1033 } 1034 1035 private void do_exchange_refresh_token_for_access_token () { 1036 debug ("ACTION: exchanging OAuth refresh token for OAuth access token."); 1037 1038 host.install_login_wait_pane (); 1039 1040 RefreshAccessTokenTransaction txn = new RefreshAccessTokenTransaction (session); 1041 1042 txn.completed.connect (on_refresh_access_token_transaction_completed); 1043 txn.network_error.connect (on_refresh_access_token_transaction_error); 1044 1045 try { 1046 txn.execute (); 1047 } catch (Spit.Publishing.PublishingError err) { 1048 host.post_error (err); 1049 } 1050 } 1051 1052 private void do_extract_tokens (string response_body) { 1053 debug ("ACTION: extracting OAuth tokens from body of server response"); 1054 1055 Json.Parser parser = new Json.Parser (); 1056 1057 try { 1058 parser.load_from_data (response_body); 1059 } catch (Error err) { 1060 host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ( 1061 "Couldn't parse JSON response: " + err.message)); 1062 return; 1063 } 1064 1065 Json.Object response_obj = parser.get_root ().get_object (); 1066 1067 if ((!response_obj.has_member ("access_token")) && (!response_obj.has_member ("refresh_token"))) { 1068 host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ( 1069 "neither access_token nor refresh_token not present in server response")); 1070 return; 1071 } 1072 1073 if (response_obj.has_member ("refresh_token")) { 1074 string refresh_token = response_obj.get_string_member ("refresh_token"); 1075 1076 if (refresh_token != "") 1077 on_refresh_token_available (refresh_token); 1078 } 1079 1080 if (response_obj.has_member ("access_token")) { 1081 string access_token = response_obj.get_string_member ("access_token"); 1082 1083 if (access_token != "") 1084 on_access_token_available (access_token); 1085 } 1086 } 1087 1088 private void do_fetch_username () { 1089 debug ("ACTION: running network transaction to fetch username."); 1090 1091 host.install_login_wait_pane (); 1092 host.set_service_locked (true); 1093 1094 UsernameFetchTransaction txn = new UsernameFetchTransaction (session); 1095 txn.completed.connect (on_fetch_username_transaction_completed); 1096 txn.network_error.connect (on_fetch_username_transaction_error); 1097 1098 try { 1099 txn.execute (); 1100 } catch (Error err) { 1101 host.post_error (err); 1102 } 1103 } 1104 1105 private void do_extract_username (string response_body) { 1106 debug ("ACTION: extracting username from body of server response"); 1107 1108 Json.Parser parser = new Json.Parser (); 1109 1110 try { 1111 parser.load_from_data (response_body); 1112 } catch (Error err) { 1113 host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ( 1114 "Couldn't parse JSON response: " + err.message)); 1115 return; 1116 } 1117 1118 Json.Object response_obj = parser.get_root ().get_object (); 1119 1120 if (response_obj.has_member ("name")) { 1121 string username = response_obj.get_string_member ("name"); 1122 1123 if (username != "") 1124 session.user_name = username; 1125 } 1126 1127 if (response_obj.has_member ("access_token")) { 1128 string access_token = response_obj.get_string_member ("access_token"); 1129 1130 if (access_token != "") 1131 session.access_token = access_token; 1132 } 1133 1134 // by the time we get a username, the session should be authenticated, or else something 1135 // really tragic has happened 1136 assert (session.is_authenticated ()); 1137 1138 on_login_flow_complete (); 1139 } 1140 1141 protected unowned Spit.Publishing.PluginHost get_host () { 1142 return host; 1143 } 1144 1145 protected GoogleSession get_session () { 1146 return session; 1147 } 1148 1149 protected void start_oauth_flow (string? refresh_token = null) { 1150 if (refresh_token != null && refresh_token != "") { 1151 session.refresh_token = refresh_token; 1152 do_exchange_refresh_token_for_access_token (); 1153 } else { 1154 if (WebAuthenticationPane.is_cache_dirty ()) { 1155 host.install_static_message_pane (_ ("You have already logged in and out of a Google service during this session.\n\nTo continue publishing to Google services, quit and restart this software, then try publishing again.")); 1156 return; 1157 } 1158 1159 do_hosted_web_authentication (); 1160 } 1161 } 1162 1163 protected abstract void on_login_flow_complete (); 1164 1165 protected abstract void do_logout (); 1166 1167 public abstract bool is_running (); 1168 1169 public abstract void start (); 1170 1171 public abstract void stop (); 1172 1173 public Spit.Publishing.Service get_service () { 1174 return service; 1175 } 1176} 1177} 1178