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