1/* 2* Copyright (c) 2012 BJA Electronics 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* Authored by: Jeroen Arnoldus (b.j.arnoldus@bja-electronics.nl) 20*/ 21 22extern string hmac_sha1 (string key, string message); 23public class TumblrService : Object, Spit.Pluggable, Spit.Publishing.Service { 24 private GLib.Icon icon; 25 26 public TumblrService (GLib.File resource_directory) { 27 icon = new ThemedIcon ("tumblr"); 28 } 29 30 public int get_pluggable_interface (int min_host_interface, int max_host_interface) { 31 return Spit.negotiate_interfaces (min_host_interface, max_host_interface, 32 Spit.Publishing.CURRENT_INTERFACE); 33 } 34 35 public unowned string get_id () { 36 return "io.elementary.photos.publishing.tumblr"; 37 } 38 39 public unowned string get_pluggable_name () { 40 return "Tumblr"; 41 } 42 43 public void get_info (ref Spit.PluggableInfo info) { 44 info.authors = "Jeroen Arnoldus"; 45 info.copyright = _ ("Copyright 2012 BJA Electronics"); 46 info.translators = Resources.TRANSLATORS; 47 info.version = _VERSION; 48 info.website_name = Resources.WEBSITE_NAME; 49 info.website_url = Resources.WEBSITE_URL; 50 info.is_license_wordwrapped = false; 51 info.license = Resources.LICENSE; 52 info.icon = icon; 53 } 54 55 public void activation (bool enabled) { 56 } 57 58 public Spit.Publishing.Publisher create_publisher (Spit.Publishing.PluginHost host) { 59 return new Publishing.Tumblr.TumblrPublisher (this, host); 60 } 61 62 public Spit.Publishing.Publisher.MediaType get_supported_media () { 63 return (Spit.Publishing.Publisher.MediaType.PHOTO | 64 Spit.Publishing.Publisher.MediaType.VIDEO); 65 } 66} 67 68namespace Publishing.Tumblr { 69 70internal const string SERVICE_NAME = "Tumblr"; 71internal const string ENDPOINT_URL = "http://www.tumblr.com/"; 72internal const string API_KEY = "NdXvXQuKVccOsCOj0H4k9HUJcbcjDBYSo2AkaHzXFECHGNuP9k"; 73internal const string API_SECRET = "BN0Uoig0MwbeD27OgA0IwYlp3Uvonyfsrl9pf1cnnMj1QoEUvi"; 74internal const string ENCODE_RFC_3986_EXTRA = "!*' ();:@&=+$,/?%#[] \\"; 75internal const int ORIGINAL_SIZE = -1; 76 77 78 79private class BlogEntry { 80 public string blog; 81 public string url; 82 public BlogEntry (string creator_blog, string creator_url) { 83 blog = creator_blog; 84 url = creator_url; 85 } 86} 87 88private class SizeEntry { 89 public string title; 90 public int size; 91 92 public SizeEntry (string creator_title, int creator_size) { 93 title = creator_title; 94 size = creator_size; 95 } 96} 97 98public class TumblrPublisher : Spit.Publishing.Publisher, GLib.Object { 99 private Spit.Publishing.Service service; 100 private Spit.Publishing.PluginHost host; 101 private Spit.Publishing.ProgressCallback progress_reporter = null; 102 private bool running = false; 103 private bool was_started = false; 104 private Session session = null; 105 private PublishingOptionsPane publishing_options_pane = null; 106 private SizeEntry[] sizes = null; 107 private BlogEntry[] blogs = null; 108 private string username = ""; 109 110 111 private SizeEntry[] create_sizes () { 112 SizeEntry[] result = new SizeEntry[0]; 113 114 result += new SizeEntry (_ ("500 x 375 pixels"), 500); 115 result += new SizeEntry (_ ("1024 x 768 pixels"), 1024); 116 result += new SizeEntry (_ ("1280 x 853 pixels"), 1280); 117 //Larger images make no sense for Tumblr 118 // result += new SizeEntry(_("2048 x 1536 pixels"), 2048); 119 // result += new SizeEntry(_("4096 x 3072 pixels"), 4096); 120 // result += new SizeEntry(_("Original size"), ORIGINAL_SIZE); 121 122 return result; 123 } 124 125 private BlogEntry[] create_blogs () { 126 BlogEntry[] result = new BlogEntry[0]; 127 128 129 return result; 130 } 131 132 public TumblrPublisher (Spit.Publishing.Service service, 133 Spit.Publishing.PluginHost host) { 134 debug ("TumblrPublisher instantiated."); 135 this.service = service; 136 this.host = host; 137 this.session = new Session (); 138 this.sizes = this.create_sizes (); 139 this.blogs = this.create_blogs (); 140 session.authenticated.connect (on_session_authenticated); 141 } 142 143 ~TumblrPublisher () { 144 session.authenticated.disconnect (on_session_authenticated); 145 } 146 147 private void invalidate_persistent_session () { 148 set_persistent_access_phase_token (""); 149 set_persistent_access_phase_token_secret (""); 150 } 151 // Publisher interface implementation 152 153 public Spit.Publishing.Service get_service () { 154 return service; 155 } 156 157 public Spit.Publishing.PluginHost get_host () { 158 return host; 159 } 160 161 public bool is_running () { 162 return running; 163 } 164 165 private bool is_persistent_session_valid () { 166 string? access_phase_token = get_persistent_access_phase_token (); 167 string? access_phase_token_secret = get_persistent_access_phase_token_secret (); 168 169 bool valid = ((access_phase_token != null) && (access_phase_token_secret != null)); 170 171 if (valid) 172 debug ("existing Tumblr session found in configuration database; using it."); 173 else 174 debug ("no persisted Tumblr session exists."); 175 176 return valid; 177 } 178 179 180 181 182 public string? get_persistent_access_phase_token () { 183 return host.get_config_string ("token", null); 184 } 185 186 private void set_persistent_access_phase_token (string? token) { 187 host.set_config_string ("token", token); 188 } 189 190 public string? get_persistent_access_phase_token_secret () { 191 return host.get_config_string ("token_secret", null); 192 } 193 194 private void set_persistent_access_phase_token_secret (string? token_secret) { 195 host.set_config_string ("token_secret", token_secret); 196 } 197 198 internal int get_persistent_default_size () { 199 return host.get_config_int ("default_size", 1); 200 } 201 202 internal void set_persistent_default_size (int size) { 203 host.set_config_int ("default_size", size); 204 } 205 206 internal int get_persistent_default_blog () { 207 return host.get_config_int ("default_blog", 0); 208 } 209 210 internal void set_persistent_default_blog (int blog) { 211 host.set_config_int ("default_blog", blog); 212 } 213 214 // Actions and events implementation 215 216 /** 217 * Action that shows the authentication pane. 218 * 219 * This action method shows the authentication pane. It is shown at the 220 * very beginning of the interaction when no persistent parameters are found 221 * or after a failed login attempt using persisted parameters. It can be 222 * given a mode flag to specify whether it should be displayed in initial 223 * mode or in any of the error modes that it supports. 224 * 225 * @param mode the mode for the authentication pane 226 */ 227 private void do_show_authentication_pane (AuthenticationPane.Mode mode = AuthenticationPane.Mode.INTRO) { 228 debug ("ACTION: installing authentication pane"); 229 230 host.set_service_locked (false); 231 AuthenticationPane authentication_pane = 232 new AuthenticationPane (this, mode); 233 authentication_pane.login.connect (on_authentication_pane_login_clicked); 234 host.install_dialog_pane (authentication_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE); 235 host.set_dialog_default_widget (authentication_pane.get_default_widget ()); 236 } 237 238 /** 239 * Event triggered when the login button in the authentication panel is 240 * clicked. 241 * 242 * This event is triggered when the login button in the authentication 243 * panel is clicked. It then triggers a network login interaction. 244 * 245 * @param username the name of the Tumblr user as entered in the dialog 246 * @param password the password of the Tumblr as entered in the dialog 247 */ 248 private void on_authentication_pane_login_clicked ( string username, string password ) { 249 debug ("EVENT: on_authentication_pane_login_clicked"); 250 if (!running) 251 return; 252 253 do_network_login (username, password); 254 } 255 256 /** 257 * Action to perform a network login to a Tumblr blog. 258 * 259 * This action performs a network login a Tumblr blog specified the given user name and password as credentials. 260 * 261 * @param username the name of the Tumblr user used to login 262 * @param password the password of the Tumblr user used to login 263 */ 264 private void do_network_login (string username, string password) { 265 debug ("ACTION: logging in"); 266 host.set_service_locked (true); 267 host.install_login_wait_pane (); 268 269 270 AccessTokenFetchTransaction txn = new AccessTokenFetchTransaction (session, username, password); 271 txn.completed.connect (on_auth_request_txn_completed); 272 txn.network_error.connect (on_auth_request_txn_error); 273 274 try { 275 txn.execute (); 276 } catch (Spit.Publishing.PublishingError err) { 277 host.post_error (err); 278 } 279 } 280 281 282 private void on_auth_request_txn_completed (Publishing.RESTSupport.Transaction txn) { 283 txn.completed.disconnect (on_auth_request_txn_completed); 284 txn.network_error.disconnect (on_auth_request_txn_error); 285 286 if (!is_running ()) 287 return; 288 289 debug ("EVENT: OAuth authentication request transaction completed; response = '%s'", 290 txn.get_response ()); 291 292 do_parse_token_info_from_auth_request (txn.get_response ()); 293 } 294 295 private void on_auth_request_txn_error (Publishing.RESTSupport.Transaction txn, 296 Spit.Publishing.PublishingError err) { 297 txn.completed.disconnect (on_auth_request_txn_completed); 298 txn.network_error.disconnect (on_auth_request_txn_error); 299 300 if (!is_running ()) 301 return; 302 303 debug ("EVENT: OAuth authentication request transaction caused a network error"); 304 host.post_error (err); 305 } 306 307 308 private void do_parse_token_info_from_auth_request (string response) { 309 debug ("ACTION: parsing authorization request response '%s' into token and secret", response); 310 311 string? oauth_token = null; 312 string? oauth_token_secret = null; 313 314 string[] key_value_pairs = response.split ("&"); 315 foreach (string pair in key_value_pairs) { 316 string[] split_pair = pair.split ("="); 317 318 if (split_pair.length != 2) 319 host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ( 320 _ ("'%s' isn't a valid response to an OAuth authentication request"), response)); 321 322 if (split_pair[0] == "oauth_token") 323 oauth_token = split_pair[1]; 324 else if (split_pair[0] == "oauth_token_secret") 325 oauth_token_secret = split_pair[1]; 326 } 327 328 if (oauth_token == null || oauth_token_secret == null) 329 host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ( 330 _ ("'%s' isn't a valid response to an OAuth authentication request"), response)); 331 332 session.set_access_phase_credentials (oauth_token, oauth_token_secret); 333 } 334 335 336 337 private void on_session_authenticated () { 338 if (!is_running ()) 339 return; 340 341 debug ("EVENT: a fully authenticated session has become available"); 342 set_persistent_access_phase_token (session.get_access_phase_token ()); 343 set_persistent_access_phase_token_secret (session.get_access_phase_token_secret ()); 344 do_get_blogs (); 345 346 } 347 348 private void do_get_blogs () { 349 debug ("ACTION: obtain all blogs of the tumblr user"); 350 UserInfoFetchTransaction txn = new UserInfoFetchTransaction (session); 351 txn.completed.connect (on_info_request_txn_completed); 352 txn.network_error.connect (on_info_request_txn_error); 353 354 try { 355 txn.execute (); 356 } catch (Spit.Publishing.PublishingError err) { 357 host.post_error (err); 358 } 359 360 361 } 362 363 364 private void on_info_request_txn_completed (Publishing.RESTSupport.Transaction txn) { 365 txn.completed.disconnect (on_info_request_txn_completed); 366 txn.network_error.disconnect (on_info_request_txn_error); 367 368 if (!is_running ()) 369 return; 370 371 debug ("EVENT: user info request transaction completed; response = '%s'", 372 txn.get_response ()); 373 do_parse_token_info_from_user_request (txn.get_response ()); 374 do_show_publishing_options_pane (); 375 } 376 377 378 private void do_parse_token_info_from_user_request (string response) { 379 debug ("ACTION: parsing info request response '%s' into list of available blogs", response); 380 try { 381 var parser = new Json.Parser (); 382 parser.load_from_data (response, -1); 383 var root_object = parser.get_root ().get_object (); 384 this.username = root_object.get_object_member ("response").get_object_member ("user").get_string_member ("name"); 385 debug ("Got user name: %s", username); 386 foreach (var blognode in root_object.get_object_member ("response").get_object_member ("user").get_array_member ("blogs").get_elements ()) { 387 var blog = blognode.get_object (); 388 string name = blog.get_string_member ("name"); 389 string url = blog.get_string_member ("url").replace ("http://", "").replace ("/", ""); 390 debug ("Got blog name: %s and url: %s", name, url); 391 this.blogs += new BlogEntry (name, url); 392 } 393 } catch (Error err) { 394 host.post_error (err); 395 } 396 } 397 398 private void on_info_request_txn_error (Publishing.RESTSupport.Transaction txn, 399 Spit.Publishing.PublishingError err) { 400 txn.completed.disconnect (on_info_request_txn_completed); 401 txn.network_error.disconnect (on_info_request_txn_error); 402 403 if (!is_running ()) 404 return; 405 406 session.deauthenticate (); 407 invalidate_persistent_session (); 408 debug ("EVENT: user info request transaction caused a network error"); 409 host.post_error (err); 410 } 411 412 private void do_show_publishing_options_pane () { 413 debug ("ACTION: displaying publishing options pane"); 414 host.set_service_locked (false); 415 PublishingOptionsPane publishing_options_pane = 416 new PublishingOptionsPane (this, host.get_publishable_media_type (), this.sizes, this.blogs, this.username); 417 publishing_options_pane.publish.connect (on_publishing_options_pane_publish); 418 publishing_options_pane.logout.connect (on_publishing_options_pane_logout); 419 host.install_dialog_pane (publishing_options_pane); 420 } 421 422 423 424 private void on_publishing_options_pane_publish () { 425 if (publishing_options_pane != null) { 426 publishing_options_pane.publish.disconnect (on_publishing_options_pane_publish); 427 publishing_options_pane.logout.disconnect (on_publishing_options_pane_logout); 428 } 429 430 if (!is_running ()) 431 return; 432 433 debug ("EVENT: user clicked the 'Publish' button in the publishing options pane"); 434 do_publish (); 435 } 436 437 private void on_publishing_options_pane_logout () { 438 if (publishing_options_pane != null) { 439 publishing_options_pane.publish.disconnect (on_publishing_options_pane_publish); 440 publishing_options_pane.logout.disconnect (on_publishing_options_pane_logout); 441 } 442 443 if (!is_running ()) 444 return; 445 446 debug ("EVENT: user clicked the 'Logout' button in the publishing options pane"); 447 448 do_logout (); 449 } 450 451 public static int tumblr_date_time_compare_func (Spit.Publishing.Publishable a, 452 Spit.Publishing.Publishable b) { 453 return a.get_exposure_date_time ().compare (b.get_exposure_date_time ()); 454 } 455 456 private void do_publish () { 457 debug ("ACTION: uploading media items to remote server."); 458 459 host.set_service_locked (true); 460 461 progress_reporter = host.serialize_publishables (sizes[get_persistent_default_size ()].size); 462 463 // Serialization is a long and potentially cancellable operation, so before we use 464 // the publishables, make sure that the publishing interaction is still running. If it 465 // isn't the publishing environment may be partially torn down so do a short-circuit 466 // return 467 if (!is_running ()) 468 return; 469 470 // Sort publishables in reverse-chronological order. 471 Spit.Publishing.Publishable[] publishables = host.get_publishables (); 472 Gee.ArrayList<Spit.Publishing.Publishable> sorted_list = 473 new Gee.ArrayList<Spit.Publishing.Publishable> (); 474 foreach (Spit.Publishing.Publishable p in publishables) { 475 debug ("ACTION: add publishable"); 476 sorted_list.add (p); 477 } 478 sorted_list.sort (tumblr_date_time_compare_func); 479 string blog_url = this.blogs[get_persistent_default_blog ()].url; 480 481 Uploader uploader = new Uploader (session, sorted_list.to_array (), blog_url); 482 uploader.upload_complete.connect (on_upload_complete); 483 uploader.upload_error.connect (on_upload_error); 484 uploader.upload (on_upload_status_updated); 485 } 486 487 private void do_show_success_pane () { 488 debug ("ACTION: showing success pane."); 489 490 host.set_service_locked (false); 491 host.install_success_pane (); 492 } 493 494 495 private void on_upload_status_updated (int file_number, double completed_fraction) { 496 if (!is_running ()) 497 return; 498 499 debug ("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); 500 501 assert (progress_reporter != null); 502 503 progress_reporter (file_number, completed_fraction); 504 } 505 506 private void on_upload_complete (Publishing.RESTSupport.BatchUploader uploader, 507 int num_published) { 508 if (!is_running ()) 509 return; 510 511 debug ("EVENT: uploader reports upload complete; %d items published.", num_published); 512 513 uploader.upload_complete.disconnect (on_upload_complete); 514 uploader.upload_error.disconnect (on_upload_error); 515 516 do_show_success_pane (); 517 } 518 519 private void on_upload_error (Publishing.RESTSupport.BatchUploader uploader, 520 Spit.Publishing.PublishingError err) { 521 if (!is_running ()) 522 return; 523 524 debug ("EVENT: uploader reports upload error = '%s'.", err.message); 525 526 uploader.upload_complete.disconnect (on_upload_complete); 527 uploader.upload_error.disconnect (on_upload_error); 528 529 host.post_error (err); 530 } 531 532 533 private void do_logout () { 534 debug ("ACTION: logging user out, deauthenticating session, and erasing stored credentials"); 535 536 session.deauthenticate (); 537 invalidate_persistent_session (); 538 539 running = false; 540 541 attempt_start (); 542 } 543 544 public void attempt_start () { 545 if (is_running ()) 546 return; 547 548 debug ("TumblrPublisher: starting interaction."); 549 550 running = true; 551 if (is_persistent_session_valid ()) { 552 debug ("attempt start: a persistent session is available; using it"); 553 554 session.authenticate_from_persistent_credentials (get_persistent_access_phase_token (), 555 get_persistent_access_phase_token_secret ()); 556 } else { 557 debug ("attempt start: no persistent session available; showing login welcome pane"); 558 559 do_show_authentication_pane (); 560 } 561 } 562 563 public void start () { 564 if (is_running ()) 565 return; 566 567 if (was_started) 568 error (_ ("TumblrPublisher: start( ): can't start; this publisher is not restartable.")); 569 570 debug ("TumblrPublisher: starting interaction."); 571 572 attempt_start (); 573 } 574 575 public void stop () { 576 debug ("TumblrPublisher: stop( ) invoked."); 577 578 // if (session != null) 579 // session.stop_transactions (); 580 581 running = false; 582 } 583 584 585 // UI elements 586 587 /** 588 * The authentication pane used when asking service URL, user name and password 589 * from the user. 590 */ 591 internal class AuthenticationPane : Spit.Publishing.DialogPane, Object { 592 public enum Mode { 593 INTRO, 594 FAILED_RETRY_USER 595 } 596 private static string intro_message = _ ("Enter the username and password associated with your Tumblr account."); 597 private static string failed_retry_user_message = _ ("Username and/or password invalid. Please try again"); 598 599 private Gtk.Box pane_widget = null; 600 private Gtk.Builder builder; 601 private Gtk.Entry username_entry; 602 private Gtk.Entry password_entry; 603 private Gtk.Button login_button; 604 605 public signal void login (string user, string password); 606 607 public AuthenticationPane (TumblrPublisher publisher, Mode mode = Mode.INTRO) { 608 this.pane_widget = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); 609 610 try { 611 builder = new Gtk.Builder (); 612 builder.add_from_resource ("/io/elementary/photos/plugins/publishing-extras/ui/tumblr_authentication_pane.ui"); 613 builder.connect_signals (null); 614 Gtk.Alignment align = builder.get_object ("alignment") as Gtk.Alignment; 615 616 Gtk.Label message_label = builder.get_object ("message_label") as Gtk.Label; 617 switch (mode) { 618 case Mode.INTRO: 619 message_label.set_text (intro_message); 620 break; 621 622 case Mode.FAILED_RETRY_USER: 623 message_label.set_markup ("<b>%s</b>\n\n%s".printf (_ ( 624 "Invalid User Name or Password"), failed_retry_user_message)); 625 break; 626 } 627 628 username_entry = builder.get_object ("username_entry") as Gtk.Entry; 629 630 password_entry = builder.get_object ("password_entry") as Gtk.Entry; 631 632 633 634 login_button = builder.get_object ("login_button") as Gtk.Button; 635 636 username_entry.changed.connect (on_user_changed); 637 password_entry.changed.connect (on_password_changed); 638 login_button.clicked.connect (on_login_button_clicked); 639 640 align.reparent (pane_widget); 641 publisher.get_host ().set_dialog_default_widget (login_button); 642 } catch (Error e) { 643 warning (_ ("Could not load UI: %s"), e.message); 644 } 645 } 646 647 public Gtk.Widget get_default_widget () { 648 return login_button; 649 } 650 651 private void on_login_button_clicked () { 652 login (username_entry.get_text (), 653 password_entry.get_text ()); 654 } 655 656 657 private void on_user_changed () { 658 update_login_button_sensitivity (); 659 } 660 661 private void on_password_changed () { 662 update_login_button_sensitivity (); 663 } 664 665 private void update_login_button_sensitivity () { 666 login_button.set_sensitive ( 667 !is_string_empty (username_entry.get_text ()) && 668 !is_string_empty (password_entry.get_text ()) 669 ); 670 } 671 672 public Gtk.Widget get_widget () { 673 return pane_widget; 674 } 675 676 public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry () { 677 return Spit.Publishing.DialogPane.GeometryOptions.NONE; 678 } 679 680 public void on_pane_installed () { 681 username_entry.grab_focus (); 682 password_entry.set_activates_default (true); 683 login_button.can_default = true; 684 update_login_button_sensitivity (); 685 } 686 687 public void on_pane_uninstalled () { 688 } 689 } 690 691 692 /** 693 * The publishing options pane. 694 */ 695 696 697 internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { 698 699 700 701 private Gtk.Builder builder; 702 private Gtk.Box pane_widget = null; 703 private Gtk.Label upload_info_label = null; 704 private Gtk.Label size_label = null; 705 private Gtk.Label blog_label = null; 706 private Gtk.Button logout_button = null; 707 private Gtk.Button publish_button = null; 708 private Gtk.ComboBoxText size_combo = null; 709 private Gtk.ComboBoxText blog_combo = null; 710 private SizeEntry[] sizes = null; 711 private BlogEntry[] blogs = null; 712 private string username = ""; 713 private TumblrPublisher publisher = null; 714 private Spit.Publishing.Publisher.MediaType media_type; 715 716 public signal void publish (); 717 public signal void logout (); 718 719 public PublishingOptionsPane (TumblrPublisher publisher, Spit.Publishing.Publisher.MediaType media_type, SizeEntry[] sizes, BlogEntry[] blogs, string username) { 720 721 this.pane_widget = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); 722 this.username = username; 723 this.publisher = publisher; 724 this.media_type = media_type; 725 this.sizes = sizes; 726 this.blogs = blogs; 727 728 try { 729 builder = new Gtk.Builder (); 730 builder.add_from_resource ("/io/elementary/photos/plugins/publishing-extras/ui/tumblr_publishing_options_pane.ui"); 731 builder.connect_signals (null); 732 733 // pull in the necessary widgets from the.ui file 734 pane_widget = (Gtk.Box) this.builder.get_object ("tumblr_pane"); 735 upload_info_label = (Gtk.Label) this.builder.get_object ("upload_info_label"); 736 logout_button = (Gtk.Button) this.builder.get_object ("logout_button"); 737 publish_button = (Gtk.Button) this.builder.get_object ("publish_button"); 738 size_combo = (Gtk.ComboBoxText) this.builder.get_object ("size_combo"); 739 size_label = (Gtk.Label) this.builder.get_object ("size_label"); 740 blog_combo = (Gtk.ComboBoxText) this.builder.get_object ("blog_combo"); 741 blog_label = (Gtk.Label) this.builder.get_object ("blog_label"); 742 743 744 string upload_label_text = _ ("You are logged into Tumblr as %s.\n\n").printf (this.username); 745 upload_info_label.set_label (upload_label_text); 746 747 populate_blog_combo (); 748 blog_combo.changed.connect (on_blog_changed); 749 750 if ((media_type != Spit.Publishing.Publisher.MediaType.VIDEO)) { 751 populate_size_combo (); 752 size_combo.changed.connect (on_size_changed); 753 } else { 754 // publishing -only- video - don't let the user manipulate the photo size choices. 755 size_combo.set_sensitive (false); 756 size_label.set_sensitive (false); 757 } 758 759 logout_button.clicked.connect (on_logout_clicked); 760 publish_button.clicked.connect (on_publish_clicked); 761 } catch (Error e) { 762 warning (_ ("Could not load UI: %s"), e.message); 763 } 764 } 765 766 767 768 769 770 private void on_logout_clicked () { 771 logout (); 772 } 773 774 private void on_publish_clicked () { 775 776 777 publish (); 778 } 779 780 781 private void populate_blog_combo () { 782 if (blogs != null) { 783 foreach (BlogEntry b in blogs) 784 blog_combo.append_text (b.blog); 785 blog_combo.set_active (publisher.get_persistent_default_blog ()); 786 } 787 } 788 789 private void on_blog_changed () { 790 publisher.set_persistent_default_blog (blog_combo.get_active ()); 791 } 792 793 private void populate_size_combo () { 794 if (sizes != null) { 795 foreach (SizeEntry e in sizes) 796 size_combo.append_text (e.title); 797 size_combo.set_active (publisher.get_persistent_default_size ()); 798 } 799 } 800 801 private void on_size_changed () { 802 publisher.set_persistent_default_size (size_combo.get_active ()); 803 } 804 805 806 protected void notify_publish () { 807 publish (); 808 } 809 810 protected void notify_logout () { 811 logout (); 812 } 813 814 public Gtk.Widget get_widget () { 815 return pane_widget; 816 } 817 818 public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry () { 819 return Spit.Publishing.DialogPane.GeometryOptions.NONE; 820 } 821 822 public void on_pane_installed () { 823 publish.connect (notify_publish); 824 logout.connect (notify_logout); 825 } 826 827 public void on_pane_uninstalled () { 828 publish.disconnect (notify_publish); 829 logout.disconnect (notify_logout); 830 } 831 } 832 833 834 // REST support classes 835 internal class Transaction : Publishing.RESTSupport.Transaction { 836 public Transaction (Session session, Publishing.RESTSupport.HttpMethod method = 837 Publishing.RESTSupport.HttpMethod.POST) { 838 base (session, method); 839 840 } 841 842 public Transaction.with_uri (Session session, string uri, 843 Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.POST) { 844 base.with_endpoint_url (session, uri, method); 845 846 add_argument ("oauth_nonce", session.get_oauth_nonce ()); 847 add_argument ("oauth_signature_method", "HMAC-SHA1"); 848 add_argument ("oauth_version", "1.0"); 849 add_argument ("oauth_timestamp", session.get_oauth_timestamp ()); 850 add_argument ("oauth_consumer_key", API_KEY); 851 if (session.get_access_phase_token () != null) { 852 add_argument ("oauth_token", session.get_access_phase_token ()); 853 } 854 } 855 856 public override void execute () throws Spit.Publishing.PublishingError { 857 ((Session) get_parent_session ()).sign_transaction (this); 858 859 base.execute (); 860 } 861 862 } 863 864 865 internal class AccessTokenFetchTransaction : Transaction { 866 public AccessTokenFetchTransaction (Session session, string username, string password) { 867 base.with_uri (session, "https://www.tumblr.com/oauth/access_token", 868 Publishing.RESTSupport.HttpMethod.POST); 869 add_argument ("x_auth_username", Soup.URI.encode (username, ENCODE_RFC_3986_EXTRA)); 870 add_argument ("x_auth_password", password); 871 add_argument ("x_auth_mode", "client_auth"); 872 } 873 } 874 875 internal class UserInfoFetchTransaction : Transaction { 876 public UserInfoFetchTransaction (Session session) { 877 base.with_uri (session, "http://api.tumblr.com/v2/user/info", 878 Publishing.RESTSupport.HttpMethod.POST); 879 } 880 } 881 882 883 internal class UploadTransaction : Publishing.RESTSupport.UploadTransaction { 884 private Session session; 885 private Publishing.RESTSupport.Argument[] auth_header_fields; 886 887 888 //Workaround for Soup.URI.encode () to support binary data (i.e. string with \0) 889 private string encode ( uint8[] data ) { 890 var s = new StringBuilder (); 891 char[] bytes = new char[2]; 892 bytes[1] = 0; 893 foreach ( var byte in data ) { 894 if (byte == 0) { 895 s.append ( "%00" ); 896 } else { 897 bytes[0] = (char)byte; 898 s.append ( Soup.URI.encode ((string) bytes, ENCODE_RFC_3986_EXTRA) ); 899 } 900 } 901 return s.str; 902 } 903 904 905 public UploadTransaction (Session session, Spit.Publishing.Publishable publishable, string blog_url) { 906 debug ("Init upload transaction"); 907 base.with_endpoint_url (session, publishable, "http://api.tumblr.com/v2/blog/%s/post".printf (blog_url) ); 908 this.session = session; 909 910 } 911 912 913 914 public void add_authorization_header_field (string key, string value) { 915 auth_header_fields += new Publishing.RESTSupport.Argument (key, value); 916 } 917 918 public Publishing.RESTSupport.Argument[] get_authorization_header_fields () { 919 return auth_header_fields; 920 } 921 922 public string get_authorization_header_string () { 923 string result = "OAuth "; 924 925 for (int i = 0; i < auth_header_fields.length; i++) { 926 result += auth_header_fields[i].key; 927 result += "="; 928 result += ("\"" + auth_header_fields[i].value + "\""); 929 930 if (i < auth_header_fields.length - 1) 931 result += ", "; 932 } 933 934 return result; 935 } 936 937 public override void execute () throws Spit.Publishing.PublishingError { 938 add_authorization_header_field ("oauth_nonce", session.get_oauth_nonce ()); 939 add_authorization_header_field ("oauth_signature_method", "HMAC-SHA1"); 940 add_authorization_header_field ("oauth_version", "1.0"); 941 add_authorization_header_field ("oauth_timestamp", session.get_oauth_timestamp ()); 942 add_authorization_header_field ("oauth_consumer_key", API_KEY); 943 add_authorization_header_field ("oauth_token", session.get_access_phase_token ()); 944 945 946 string payload; 947 size_t payload_length; 948 try { 949 FileUtils.get_contents (base.publishable.get_serialized_file ().get_path (), out payload, 950 out payload_length); 951 952 string reqdata = this.encode (payload.data[0:payload_length]); 953 954 955 956 add_argument ("data[0]", reqdata); 957 add_argument ("type", "photo"); 958 string[] keywords = base.publishable.get_publishing_keywords (); 959 string tags = ""; 960 if (keywords != null) { 961 foreach (string tag in keywords) { 962 if (!is_string_empty (tags)) { 963 tags += ","; 964 } 965 tags += tag; 966 } 967 } 968 add_argument ("tags", Soup.URI.encode (tags, ENCODE_RFC_3986_EXTRA)); 969 970 } catch (FileError e) { 971 throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR ( 972 _ ("A temporary file needed for publishing is unavailable")); 973 974 } 975 976 977 session.sign_transaction (this); 978 979 string authorization_header = get_authorization_header_string (); 980 981 debug ("executing upload transaction: authorization header string = '%s'", 982 authorization_header); 983 add_header ("Authorization", authorization_header); 984 985 Publishing.RESTSupport.Argument[] request_arguments = get_arguments (); 986 assert (request_arguments.length > 0); 987 988 string request_data = ""; 989 for (int i = 0; i < request_arguments.length; i++) { 990 request_data += (request_arguments[i].key + "=" + request_arguments[i].value); 991 if (i < request_arguments.length - 1) 992 request_data += "&"; 993 } 994 Soup.Message outbound_message = new Soup.Message ( "POST", get_endpoint_url ()); 995 outbound_message.set_request ("application/x-www-form-urlencoded", Soup.MemoryUse.COPY, request_data.data); 996 997 // TODO: there must be a better way to iterate over a map 998 Gee.MapIterator<string, string> i = base.message_headers.map_iterator (); 999 bool cont = i.next (); 1000 while (cont) { 1001 outbound_message.request_headers.append (i.get_key (), i.get_value ()); 1002 cont = i.next (); 1003 } 1004 set_message (outbound_message); 1005 1006 set_is_executed (true); 1007 1008 send (); 1009 } 1010 } 1011 1012 1013 1014 internal class Uploader : Publishing.RESTSupport.BatchUploader { 1015 private string blog_url = ""; 1016 public Uploader (Session session, Spit.Publishing.Publishable[] publishables, string blog_url) { 1017 base (session, publishables); 1018 this.blog_url = blog_url; 1019 1020 } 1021 1022 1023 protected override Publishing.RESTSupport.Transaction create_transaction ( 1024 Spit.Publishing.Publishable publishable) { 1025 debug ("Create upload transaction"); 1026 return new UploadTransaction ((Session) get_session (), get_current_publishable (), this.blog_url); 1027 1028 } 1029 } 1030 1031 /** 1032 * Session class that keeps track of the authentication status and of the 1033 * user token tumblr. 1034 */ 1035 internal class Session : Publishing.RESTSupport.Session { 1036 private string? access_phase_token = null; 1037 private string? access_phase_token_secret = null; 1038 1039 1040 public Session () { 1041 base (ENDPOINT_URL); 1042 } 1043 1044 public override bool is_authenticated () { 1045 return (access_phase_token != null && access_phase_token_secret != null); 1046 } 1047 1048 public void authenticate_from_persistent_credentials (string token, string secret) { 1049 this.access_phase_token = token; 1050 this.access_phase_token_secret = secret; 1051 1052 1053 authenticated (); 1054 } 1055 1056 public void deauthenticate () { 1057 access_phase_token = null; 1058 access_phase_token_secret = null; 1059 } 1060 1061 public void sign_transaction (Publishing.RESTSupport.Transaction txn) { 1062 string http_method = txn.get_method ().to_string (); 1063 1064 debug ("signing transaction with parameters:"); 1065 debug ("HTTP method = " + http_method); 1066 string? signing_key = null; 1067 if (access_phase_token_secret != null) { 1068 debug ("access phase token secret available; using it as signing key"); 1069 1070 signing_key = API_SECRET + "&" + this.get_access_phase_token_secret (); 1071 } else { 1072 debug ("Access phase token secret not available; using API " + 1073 "key as signing key"); 1074 1075 signing_key = API_SECRET + "&"; 1076 } 1077 1078 1079 Publishing.RESTSupport.Argument[] base_string_arguments = txn.get_arguments (); 1080 1081 UploadTransaction? upload_txn = txn as UploadTransaction; 1082 if (upload_txn != null) { 1083 debug ("this transaction is an UploadTransaction; including Authorization header " + 1084 "fields in signature base string"); 1085 1086 Publishing.RESTSupport.Argument[] auth_header_args = 1087 upload_txn.get_authorization_header_fields (); 1088 1089 foreach (Publishing.RESTSupport.Argument arg in auth_header_args) 1090 base_string_arguments += arg; 1091 } 1092 1093 Publishing.RESTSupport.Argument[] sorted_args = 1094 Publishing.RESTSupport.Argument.sort (base_string_arguments); 1095 1096 string arguments_string = ""; 1097 for (int i = 0; i < sorted_args.length; i++) { 1098 arguments_string += (sorted_args[i].key + "=" + sorted_args[i].value); 1099 if (i < sorted_args.length - 1) 1100 arguments_string += "&"; 1101 } 1102 1103 1104 string signature_base_string = http_method + "&" + Soup.URI.encode ( 1105 txn.get_endpoint_url (), ENCODE_RFC_3986_EXTRA) + "&" + 1106 Soup.URI.encode (arguments_string, ENCODE_RFC_3986_EXTRA); 1107 1108 debug ("signature base string = '%s'", signature_base_string); 1109 debug ("signing key = '%s'", signing_key); 1110 1111 // compute the signature 1112 string signature = hmac_sha1 (signing_key, signature_base_string); 1113 debug ("signature = '%s'", signature); 1114 signature = Soup.URI.encode (signature, ENCODE_RFC_3986_EXTRA); 1115 1116 debug ("signature after RFC encode = '%s'", signature); 1117 1118 if (upload_txn != null) 1119 upload_txn.add_authorization_header_field ("oauth_signature", signature); 1120 else 1121 txn.add_argument ("oauth_signature", signature); 1122 1123 1124 } 1125 1126 public void set_access_phase_credentials (string token, string secret) { 1127 this.access_phase_token = token; 1128 this.access_phase_token_secret = secret; 1129 1130 1131 authenticated (); 1132 } 1133 1134 public string get_access_phase_token () { 1135 return access_phase_token; 1136 } 1137 1138 1139 public string get_access_phase_token_secret () { 1140 return access_phase_token_secret; 1141 } 1142 1143 public string get_oauth_nonce () { 1144 var currtime = new DateTime.now_local (); 1145 //Note: Is this random/unique enough? 1146 return Checksum.compute_for_string (ChecksumType.MD5, currtime.to_unix ().to_string () + 1147 currtime.get_microsecond ().to_string ()); 1148 } 1149 1150 public string get_oauth_timestamp () { 1151 return GLib.get_real_time ().to_string ().substring (0, 10); 1152 } 1153 } 1154 1155 1156} //class TumblrPublisher 1157 1158public inline bool is_string_empty (string? s) { 1159 return (s == null || s[0] == '\0'); 1160} 1161} //namespace Publishing.Tumblr 1162 1163