1/* Copyright 2010+ Evgeniy Polyakov <zbr@ioremap.net> 2 * 3 * This software is licensed under the GNU LGPL (version 2.1 or later). 4 * See the COPYING file in this distribution. 5 */ 6 7public class YandexService : Object, Spit.Pluggable, Spit.Publishing.Service { 8 public int get_pluggable_interface(int min_host_interface, int max_host_interface) { 9 return Spit.negotiate_interfaces(min_host_interface, max_host_interface, Spit.Publishing.CURRENT_INTERFACE); 10 } 11 12 public unowned string get_id() { 13 return "org.yorba.shotwell.publishing.yandex-fotki"; 14 } 15 16 public unowned string get_pluggable_name() { 17 return "Yandex.Fotki"; 18 } 19 20 public void get_info(ref Spit.PluggableInfo info) { 21 info.authors = "Evgeniy Polyakov <zbr@ioremap.net>"; 22 info.copyright = _("Copyright 2010+ Evgeniy Polyakov <zbr@ioremap.net>"); 23 info.translators = Resources.TRANSLATORS; 24 info.version = _VERSION; 25 info.website_name = _("Visit the Yandex.Fotki web site"); 26 info.website_url = "https://fotki.yandex.ru/"; 27 info.is_license_wordwrapped = false; 28 info.license = Resources.LICENSE; 29 } 30 31 public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { 32 return new Publishing.Yandex.YandexPublisher(this, host); 33 } 34 35 public Spit.Publishing.Publisher.MediaType get_supported_media() { 36 return (Spit.Publishing.Publisher.MediaType.PHOTO); 37 } 38 39 public void activation(bool enabled) { 40 } 41} 42 43namespace Publishing.Yandex { 44 45internal const string SERVICE_NAME = "Yandex.Fotki"; 46 47private const string client_id = "52be4756dee3438792c831a75d7cd360"; 48 49internal class Transaction: Publishing.RESTSupport.Transaction { 50 public Transaction.with_url(Session session, string url, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.GET) { 51 base.with_endpoint_url(session, url, method); 52 add_headers(); 53 } 54 55 private void add_headers() { 56 if (((Session) get_parent_session()).is_authenticated()) { 57 add_header("Authorization", "OAuth %s".printf(((Session) get_parent_session()).get_auth_token())); 58 add_header("Connection", "close"); 59 } 60 } 61 62 public Transaction(Session session, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.GET) { 63 base(session, method); 64 add_headers(); 65 } 66 67 public void add_data(string type, string data) { 68 set_custom_payload(data, type); 69 } 70} 71 72internal class Session : Publishing.RESTSupport.Session { 73 private string? auth_token = null; 74 75 public Session() { 76 } 77 78 public override bool is_authenticated() { 79 return (auth_token != null); 80 } 81 82 public void deauthenticate() { 83 auth_token = null; 84 } 85 86 public void set_auth_token(string token) { 87 this.auth_token = token; 88 } 89 90 public string? get_auth_token() { 91 return auth_token; 92 } 93} 94 95internal class WebAuthPane : Shotwell.Plugins.Common.WebAuthenticationPane { 96 private Regex re; 97 98 public signal void login_succeeded(string success_url); 99 public signal void login_failed(); 100 101 public WebAuthPane(string login_url) { 102 Object (login_uri : login_url, 103 preferred_geometry : 104 Spit.Publishing.DialogPane.GeometryOptions.RESIZABLE); 105 } 106 107 public override void constructed () { 108 try { 109 this.re = new Regex("(.*)#access_token=([a-zA-Z0-9]*)&"); 110 } catch (RegexError e) { 111 assert_not_reached (); 112 } 113 114 this.get_view ().decide_policy.connect (on_decide_policy); 115 } 116 117 public override void on_page_load () { } 118 119 private bool on_decide_policy (WebKit.PolicyDecision decision, 120 WebKit.PolicyDecisionType type) { 121 switch (type) { 122 case WebKit.PolicyDecisionType.NAVIGATION_ACTION: 123 WebKit.NavigationPolicyDecision n_decision = (WebKit.NavigationPolicyDecision) decision; 124 WebKit.NavigationAction action = n_decision.navigation_action; 125 string uri = action.get_request().uri; 126 debug("Navigating to '%s'", uri); 127 128 MatchInfo info = null; 129 130 if (re.match(uri, 0, out info)) { 131 string access_token = info.fetch_all()[2]; 132 133 debug("Load completed: %s", access_token); 134 this.set_cursor (Gdk.CursorType.LEFT_PTR); 135 if (access_token != null) { 136 login_succeeded(access_token); 137 decision.ignore(); 138 break; 139 } else 140 login_failed(); 141 } 142 decision.use(); 143 break; 144 case WebKit.PolicyDecisionType.RESPONSE: 145 decision.use(); 146 break; 147 default: 148 return false; 149 } 150 return true; 151 } 152} 153 154internal class PublishOptions { 155 public bool disable_comments = false; 156 public bool hide_original = false; 157 public string access_type; 158 159 public string destination_album = null; 160 public string destination_album_url = null; 161} 162 163internal class PublishingOptionsPane: Spit.Publishing.DialogPane, GLib.Object { 164 private Gtk.Box box; 165 private Gtk.Builder builder; 166 private Gtk.Button logout_button; 167 private Gtk.Button publish_button; 168 private Gtk.ComboBoxText album_list; 169 170 private weak PublishOptions options; 171 172 public signal void publish(); 173 public signal void logout(); 174 175 public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { 176 return Spit.Publishing.DialogPane.GeometryOptions.NONE; 177 } 178 public void on_pane_installed() { 179 } 180 public void on_pane_uninstalled() { 181 } 182 public Gtk.Widget get_widget() { 183 return box; 184 } 185 186 public PublishingOptionsPane(PublishOptions options, Gee.HashMap<string, string> list, 187 Spit.Publishing.PluginHost host) { 188 this.options = options; 189 190 box = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); 191 192 try { 193 builder = new Gtk.Builder(); 194 builder.add_from_resource (Resources.RESOURCE_PATH + "/yandex_publish_model.ui"); 195 196 builder.connect_signals(null); 197 var content = builder.get_object ("content") as Gtk.Widget; 198 199 album_list = builder.get_object ("album_list") as Gtk.ComboBoxText; 200 foreach (string key in list.keys) 201 album_list.append_text(key); 202 203 album_list.set_active(0); 204 205 publish_button = builder.get_object("publish_button") as Gtk.Button; 206 logout_button = builder.get_object("logout_button") as Gtk.Button; 207 208 publish_button.clicked.connect(on_publish_clicked); 209 logout_button.clicked.connect(on_logout_clicked); 210 211 content.parent.remove (content); 212 box.pack_start (content, true, true, 0); 213 } catch (Error e) { 214 warning("Could not load UI: %s", e.message); 215 } 216 } 217 218 private void on_logout_clicked() { 219 logout(); 220 } 221 222 private void on_publish_clicked() { 223 options.destination_album = album_list.get_active_text(); 224 225 Gtk.CheckButton tmp = builder.get_object("hide_original_check") as Gtk.CheckButton; 226 options.hide_original = tmp.active; 227 228 tmp = builder.get_object("disable_comments_check") as Gtk.CheckButton; 229 options.disable_comments = tmp.active; 230 231 Gtk.ComboBoxText access_type = builder.get_object("access_type_list") as Gtk.ComboBoxText; 232 options.access_type = access_type.get_active_text(); 233 234 publish(); 235 } 236} 237 238private class Uploader: Publishing.RESTSupport.BatchUploader { 239 private weak PublishOptions options; 240 241 public Uploader(Session session, PublishOptions options, Spit.Publishing.Publishable[] photos) { 242 base(session, photos); 243 244 this.options = options; 245 } 246 247 protected override Publishing.RESTSupport.Transaction create_transaction(Spit.Publishing.Publishable publishable) { 248 debug("create transaction"); 249 return new UploadTransaction(((Session) get_session()), options, get_current_publishable()); 250 } 251} 252 253private class UploadTransaction: Transaction { 254 public UploadTransaction(Session session, PublishOptions options, Spit.Publishing.Publishable photo) { 255 base.with_url(session, options.destination_album_url, Publishing.RESTSupport.HttpMethod.POST); 256 257 set_custom_payload("qwe", "image/jpeg", 1); 258 259 debug("Uploading '%s' -> %s : %s", photo.get_publishing_name(), options.destination_album, options.destination_album_url); 260 261 Soup.Multipart message_parts = new Soup.Multipart("multipart/form-data"); 262 message_parts.append_form_string("title", photo.get_publishing_name()); 263 message_parts.append_form_string("hide_original", options.hide_original.to_string()); 264 message_parts.append_form_string("disable_comments", options.disable_comments.to_string()); 265 message_parts.append_form_string("access", options.access_type.down()); 266 267 string photo_data; 268 size_t data_length; 269 270 try { 271 FileUtils.get_contents(photo.get_serialized_file().get_path(), out photo_data, out data_length); 272 } catch (GLib.FileError e) { 273 critical("Failed to read data file '%s': %s", photo.get_serialized_file().get_path(), e.message); 274 } 275 276 int image_part_num = message_parts.get_length(); 277 278 Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.COPY, photo_data.data[0:data_length]); 279 message_parts.append_form_file("", photo.get_serialized_file().get_path(), "image/jpeg", bindable_data); 280 281 unowned Soup.MessageHeaders image_part_header; 282 unowned Soup.Buffer image_part_body; 283 message_parts.get_part(image_part_num, out image_part_header, out image_part_body); 284 285 GLib.HashTable<string, string> result = new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal); 286 result.insert("name", "image"); 287 result.insert("filename", "unused"); 288 289 image_part_header.set_content_disposition("form-data", result); 290 291 Soup.Message outbound_message = Soup.Form.request_new_from_multipart(get_endpoint_url(), message_parts); 292 outbound_message.request_headers.append("Authorization", ("OAuth %s").printf(session.get_auth_token())); 293 outbound_message.request_headers.append("Connection", "close"); 294 set_message(outbound_message); 295 } 296} 297 298public class YandexPublisher : Spit.Publishing.Publisher, GLib.Object { 299 private weak Spit.Publishing.PluginHost host = null; 300 private Spit.Publishing.ProgressCallback progress_reporter = null; 301 private weak Spit.Publishing.Service service = null; 302 303 private string service_url = null; 304 305 private Gee.HashMap<string, string> album_list = null; 306 private PublishOptions options; 307 308 private bool running = false; 309 310 private WebAuthPane web_auth_pane = null; 311 312 private Session session; 313 314 public YandexPublisher(Spit.Publishing.Service service, Spit.Publishing.PluginHost host) { 315 this.service = service; 316 this.host = host; 317 this.session = new Session(); 318 this.album_list = new Gee.HashMap<string, string>(); 319 this.options = new PublishOptions(); 320 } 321 322 internal string? get_persistent_auth_token() { 323 return host.get_config_string("auth_token", null); 324 } 325 326 internal void set_persistent_auth_token(string auth_token) { 327 host.set_config_string("auth_token", auth_token); 328 } 329 330 internal void invalidate_persistent_session() { 331 host.unset_config_key("auth_token"); 332 } 333 334 internal bool is_persistent_session_available() { 335 return (get_persistent_auth_token() != null); 336 } 337 338 public bool is_running() { 339 return running; 340 } 341 342 public Spit.Publishing.Service get_service() { 343 return service; 344 } 345 346 private new string? check_response(Publishing.RESTSupport.XmlDocument doc) { 347 return null; 348 } 349 350 private void parse_album_entry(Xml.Node *e) throws Spit.Publishing.PublishingError { 351 string title = null; 352 string link = null; 353 354 for (Xml.Node* c = e->children ; c != null; c = c->next) { 355 if (c->name == "title") 356 title = c->get_content(); 357 358 if ((c->name == "link") && (c->get_prop("rel") == "photos")) 359 link = c->get_prop("href"); 360 361 if (title != null && link != null) { 362 debug("Added album: '%s', link: %s", title, link); 363 album_list.set(title, link); 364 title = null; 365 link = null; 366 break; 367 } 368 } 369 } 370 371 public void parse_album_creation(string data) throws Spit.Publishing.PublishingError { 372 Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(data, check_response); 373 Xml.Node *root = doc.get_root_node(); 374 375 parse_album_entry(root); 376 } 377 378 public void parse_album_list(string data) throws Spit.Publishing.PublishingError { 379 Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(data, check_response); 380 Xml.Node *root = doc.get_root_node(); 381 382 for (Xml.Node *e = root->children ; e != null; e = e->next) { 383 if (e->name != "entry") 384 continue; 385 386 parse_album_entry(e); 387 } 388 } 389 390 private void album_creation_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) { 391 t.completed.disconnect(album_creation_complete); 392 t.network_error.disconnect(album_creation_error); 393 394 warning("Album creation error: %s", err.message); 395 } 396 397 private void album_creation_complete(Publishing.RESTSupport.Transaction t) { 398 t.completed.disconnect(album_creation_complete); 399 t.network_error.disconnect(album_creation_error); 400 401 try { 402 parse_album_creation(t.get_response()); 403 } catch (Spit.Publishing.PublishingError err) { 404 host.post_error(err); 405 return; 406 } 407 408 if (album_list.get(options.destination_album) != null) 409 start_upload(); 410 else 411 host.post_error(new Spit.Publishing.PublishingError.PROTOCOL_ERROR("Server did not create album")); 412 } 413 414 private void create_destination_album() { 415 string album = options.destination_album; 416 string data = "<entry xmlns=\"http://www.w3.org/2005/Atom\" xmlns:f=\"yandex:fotki\"><title>%s</title></entry>".printf(album); 417 418 Transaction t = new Transaction.with_url(session, service_url, Publishing.RESTSupport.HttpMethod.POST); 419 420 t.add_data("application/atom+xml; charset=utf-8; type=entry", data); 421 422 t.completed.connect(album_creation_complete); 423 t.network_error.connect(album_creation_error); 424 425 try { 426 t.execute(); 427 } catch (Spit.Publishing.PublishingError err) { 428 host.post_error(err); 429 } 430 } 431 432 private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, int num_published) { 433 uploader.upload_complete.disconnect(on_upload_complete); 434 uploader.upload_error.disconnect(on_upload_error); 435 436 if (num_published == 0) 437 host.post_error(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR("")); 438 439 host.set_service_locked(false); 440 441 host.install_success_pane(); 442 } 443 444 private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader, Spit.Publishing.PublishingError err) { 445 uploader.upload_complete.disconnect(on_upload_complete); 446 uploader.upload_error.disconnect(on_upload_error); 447 448 warning("Photo upload error: %s", err.message); 449 } 450 451 private void on_upload_status_updated(int file_number, double completed_fraction) { 452 debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); 453 454 assert(progress_reporter != null); 455 456 progress_reporter(file_number, completed_fraction); 457 } 458 459 private void start_upload() { 460 host.set_service_locked(true); 461 462 progress_reporter = host.serialize_publishables(0); 463 464 options.destination_album_url = album_list.get(options.destination_album); 465 Spit.Publishing.Publishable[] publishables = host.get_publishables(); 466 Uploader uploader = new Uploader(session, options, publishables); 467 468 uploader.upload_complete.connect(on_upload_complete); 469 uploader.upload_error.connect(on_upload_error); 470 uploader.upload(on_upload_status_updated); 471 } 472 473 private void on_logout() { 474 if (!is_running()) 475 return; 476 477 session.deauthenticate(); 478 invalidate_persistent_session(); 479 480 running = false; 481 482 start(); 483 } 484 485 private void on_publish() { 486 debug("Going to publish to '%s' : %s", options.destination_album, album_list.get(options.destination_album)); 487 if (album_list.get(options.destination_album) == null) 488 create_destination_album(); 489 else 490 start_upload(); 491 } 492 493 public void service_get_album_list_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) { 494 t.completed.disconnect(service_get_album_list_complete); 495 t.network_error.disconnect(service_get_album_list_error); 496 497 invalidate_persistent_session(); 498 warning("Failed to get album list: %s", err.message); 499 } 500 501 public void service_get_album_list_complete(Publishing.RESTSupport.Transaction t) { 502 t.completed.disconnect(service_get_album_list_complete); 503 t.network_error.disconnect(service_get_album_list_error); 504 505 debug("service_get_album_list_complete: %s", t.get_response()); 506 try { 507 parse_album_list(t.get_response()); 508 } catch (Spit.Publishing.PublishingError err) { 509 host.post_error(err); 510 } 511 512 PublishingOptionsPane publishing_options_pane = new PublishingOptionsPane(options, album_list, 513 host); 514 515 publishing_options_pane.publish.connect(on_publish); 516 publishing_options_pane.logout.connect(on_logout); 517 host.install_dialog_pane(publishing_options_pane); 518 } 519 520 public void service_get_album_list(string url) { 521 service_url = url; 522 523 Transaction t = new Transaction.with_url(session, url); 524 t.completed.connect(service_get_album_list_complete); 525 t.network_error.connect(service_get_album_list_error); 526 527 try { 528 t.execute(); 529 } catch (Spit.Publishing.PublishingError err) { 530 host.post_error(err); 531 } 532 } 533 534 public void fetch_account_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) { 535 t.completed.disconnect(fetch_account_complete); 536 t.network_error.disconnect(fetch_account_error); 537 538 warning("Failed to fetch account info: %s", err.message); 539 } 540 541 public void fetch_account_complete(Publishing.RESTSupport.Transaction t) { 542 t.completed.disconnect(fetch_account_complete); 543 t.network_error.disconnect(fetch_account_error); 544 545 debug("account info: %s", t.get_response()); 546 try { 547 Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(t.get_response(), check_response); 548 Xml.Node* root = doc.get_root_node(); 549 550 for (Xml.Node* work = root->children ; work != null; work = work->next) { 551 if (work->name != "workspace") 552 continue; 553 for (Xml.Node* c = work->children ; c != null; c = c->next) { 554 if (c->name != "collection") 555 continue; 556 557 if (c->get_prop("id") == "album-list") { 558 string url = c->get_prop("href"); 559 560 set_persistent_auth_token(session.get_auth_token()); 561 service_get_album_list(url); 562 break; 563 } 564 } 565 } 566 } catch (Spit.Publishing.PublishingError err) { 567 host.post_error(err); 568 } 569 } 570 571 public void fetch_account_information(string auth_token) { 572 session.set_auth_token(auth_token); 573 574 Transaction t = new Transaction.with_url(session, "https://api-fotki.yandex.ru/api/me/"); 575 t.completed.connect(fetch_account_complete); 576 t.network_error.connect(fetch_account_error); 577 578 try { 579 t.execute(); 580 } catch (Spit.Publishing.PublishingError err) { 581 host.post_error(err); 582 } 583 } 584 585 private void web_auth_login_succeeded(string access_token) { 586 debug("login succeeded with token %s", access_token); 587 588 host.set_service_locked(true); 589 host.install_account_fetch_wait_pane(); 590 591 fetch_account_information(access_token); 592 } 593 594 private void web_auth_login_failed() { 595 debug("login failed"); 596 } 597 598 private void start_web_auth() { 599 host.set_service_locked(false); 600 601 web_auth_pane = new WebAuthPane(("https://oauth.yandex.ru/authorize?client_id=%s&response_type=token").printf(client_id)); 602 web_auth_pane.login_succeeded.connect(web_auth_login_succeeded); 603 web_auth_pane.login_failed.connect(web_auth_login_failed); 604 605 host.install_dialog_pane(web_auth_pane, Spit.Publishing.PluginHost.ButtonMode.CANCEL); 606 } 607 608 private void show_welcome_page() { 609 host.install_welcome_pane(_("You are not currently logged into Yandex.Fotki."), 610 start_web_auth); 611 } 612 613 public void start() { 614 if (is_running()) 615 return; 616 617 if (host == null) 618 error("YandexPublisher: start( ): can't start; this publisher is not restartable."); 619 620 debug("YandexPublisher: starting interaction."); 621 622 running = true; 623 624 if (is_persistent_session_available()) { 625 session.set_auth_token(get_persistent_auth_token()); 626 627 fetch_account_information(get_persistent_auth_token()); 628 } else { 629 show_welcome_page(); 630 } 631 } 632 633 public void stop() { 634 debug("YandexPublisher: stop( ) invoked."); 635 636 host = null; 637 running = false; 638 } 639} 640 641} 642 643