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