1
/* Copyright 2012 BJA Electronics
2
* Author: Jeroen Arnoldus (b.j.arnoldus@bja-electronics.nl)
4
* This software is licensed under the GNU Lesser General Public License
5
* (version 2.1 or later). See the COPYING file in this distribution.
9
using Publishing.Extras;
12
extern string hmac_sha1(string key, string message);
13
public class TumblrService : Object, Spit.Pluggable, Spit.Publishing.Service {
14
private const string ICON_FILENAME = "tumblr.png";
16
private static Gdk.Pixbuf[] icon_pixbuf_set = null;
18
public TumblrService(GLib.File resource_directory) {
19
if (icon_pixbuf_set == null)
20
icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME));
23
public int get_pluggable_interface(int min_host_interface, int max_host_interface) {
24
return Spit.negotiate_interfaces(min_host_interface, max_host_interface,
25
Spit.Publishing.CURRENT_INTERFACE);
28
public unowned string get_id() {
29
return "org.yorba.shotwell.publishing.tumblr";
32
public unowned string get_pluggable_name() {
36
public void get_info(ref Spit.PluggableInfo info) {
37
info.authors = "Jeroen Arnoldus";
38
info.copyright = _t("Copyright 2012 BJA Electronics");
39
info.translators = Resources.TRANSLATORS;
40
info.version = _VERSION;
41
info.website_name = Resources.WEBSITE_NAME;
42
info.website_url = Resources.WEBSITE_URL;
43
info.is_license_wordwrapped = false;
44
info.license = Resources.LICENSE;
45
info.icons = icon_pixbuf_set;
48
public void activation(bool enabled) {
51
public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) {
52
return new Publishing.Tumblr.TumblrPublisher(this, host);
55
public Spit.Publishing.Publisher.MediaType get_supported_media() {
56
return (Spit.Publishing.Publisher.MediaType.PHOTO |
57
Spit.Publishing.Publisher.MediaType.VIDEO);
61
namespace Publishing.Tumblr {
63
internal const string SERVICE_NAME = "Tumblr";
64
internal const string ENDPOINT_URL = "http://www.tumblr.com/";
65
internal const string API_KEY = "NdXvXQuKVccOsCOj0H4k9HUJcbcjDBYSo2AkaHzXFECHGNuP9k";
66
internal const string API_SECRET = "BN0Uoig0MwbeD27OgA0IwYlp3Uvonyfsrl9pf1cnnMj1QoEUvi";
67
internal const string ENCODE_RFC_3986_EXTRA = "!*'();:@&=+$,/?%#[] \\";
68
internal const int ORIGINAL_SIZE = -1;
72
private class BlogEntry {
75
public BlogEntry(string creator_blog, string creator_url) {
81
private class SizeEntry {
85
public SizeEntry(string creator_title, int creator_size) {
86
title = creator_title;
91
public class TumblrPublisher : Spit.Publishing.Publisher, GLib.Object {
92
private Spit.Publishing.Service service;
93
private Spit.Publishing.PluginHost host;
94
private Spit.Publishing.ProgressCallback progress_reporter = null;
95
private bool running = false;
96
private bool was_started = false;
97
private Session session = null;
98
private PublishingOptionsPane publishing_options_pane = null;
99
private SizeEntry[] sizes = null;
100
private BlogEntry[] blogs = null;
101
private string username = "";
104
private SizeEntry[] create_sizes() {
105
SizeEntry[] result = new SizeEntry[0];
107
result += new SizeEntry(_t("500 x 375 pixels"), 500);
108
result += new SizeEntry(_t("1024 x 768 pixels"), 1024);
109
result += new SizeEntry(_t("1280 x 853 pixels"), 1280);
110
//Larger images make no sense for Tumblr
111
// result += new SizeEntry(_("2048 x 1536 pixels"), 2048);
112
// result += new SizeEntry(_("4096 x 3072 pixels"), 4096);
113
// result += new SizeEntry(_("Original size"), ORIGINAL_SIZE);
118
private BlogEntry[] create_blogs() {
119
BlogEntry[] result = new BlogEntry[0];
125
public TumblrPublisher(Spit.Publishing.Service service,
126
Spit.Publishing.PluginHost host) {
127
debug("TumblrPublisher instantiated.");
128
this.service = service;
130
this.session = new Session();
131
this.sizes = this.create_sizes();
132
this.blogs = this.create_blogs();
133
session.authenticated.connect(on_session_authenticated);
137
session.authenticated.disconnect(on_session_authenticated);
140
private void invalidate_persistent_session() {
141
set_persistent_access_phase_token("");
142
set_persistent_access_phase_token_secret("");
144
// Publisher interface implementation
146
public Spit.Publishing.Service get_service() {
150
public Spit.Publishing.PluginHost get_host() {
154
public bool is_running() {
158
private bool is_persistent_session_valid() {
159
string? access_phase_token = get_persistent_access_phase_token();
160
string? access_phase_token_secret = get_persistent_access_phase_token_secret();
162
bool valid = ((access_phase_token != null) && (access_phase_token_secret != null));
165
debug("existing Tumblr session found in configuration database; using it.");
167
debug("no persisted Tumblr session exists.");
175
public string? get_persistent_access_phase_token() {
176
return host.get_config_string("token", null);
179
private void set_persistent_access_phase_token(string? token) {
180
host.set_config_string("token", token);
183
public string? get_persistent_access_phase_token_secret() {
184
return host.get_config_string("token_secret", null);
187
private void set_persistent_access_phase_token_secret(string? token_secret) {
188
host.set_config_string("token_secret", token_secret);
191
internal int get_persistent_default_size() {
192
return host.get_config_int("default_size", 1);
195
internal void set_persistent_default_size(int size) {
196
host.set_config_int("default_size", size);
199
internal int get_persistent_default_blog() {
200
return host.get_config_int("default_blog", 0);
203
internal void set_persistent_default_blog(int blog) {
204
host.set_config_int("default_blog", blog);
207
// Actions and events implementation
210
* Action that shows the authentication pane.
212
* This action method shows the authentication pane. It is shown at the
213
* very beginning of the interaction when no persistent parameters are found
214
* or after a failed login attempt using persisted parameters. It can be
215
* given a mode flag to specify whether it should be displayed in initial
216
* mode or in any of the error modes that it supports.
218
* @param mode the mode for the authentication pane
220
private void do_show_authentication_pane(AuthenticationPane.Mode mode = AuthenticationPane.Mode.INTRO) {
221
debug("ACTION: installing authentication pane");
223
host.set_service_locked(false);
224
AuthenticationPane authentication_pane =
225
new AuthenticationPane(this, mode);
226
authentication_pane.login.connect(on_authentication_pane_login_clicked);
227
host.install_dialog_pane(authentication_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE);
228
host.set_dialog_default_widget(authentication_pane.get_default_widget());
232
* Event triggered when the login button in the authentication panel is
235
* This event is triggered when the login button in the authentication
236
* panel is clicked. It then triggers a network login interaction.
238
* @param username the name of the Tumblr user as entered in the dialog
239
* @param password the password of the Tumblr as entered in the dialog
241
private void on_authentication_pane_login_clicked( string username, string password ) {
242
debug("EVENT: on_authentication_pane_login_clicked");
246
do_network_login(username, password);
250
* Action to perform a network login to a Tumblr blog.
252
* This action performs a network login a Tumblr blog specified the given user name and password as credentials.
254
* @param username the name of the Tumblr user used to login
255
* @param password the password of the Tumblr user used to login
257
private void do_network_login(string username, string password) {
258
debug("ACTION: logging in");
259
host.set_service_locked(true);
260
host.install_login_wait_pane();
263
AccessTokenFetchTransaction txn = new AccessTokenFetchTransaction(session,username,password);
264
txn.completed.connect(on_auth_request_txn_completed);
265
txn.network_error.connect(on_auth_request_txn_error);
269
} catch (Spit.Publishing.PublishingError err) {
270
host.post_error(err);
275
private void on_auth_request_txn_completed(Publishing.RESTSupport.Transaction txn) {
276
txn.completed.disconnect(on_auth_request_txn_completed);
277
txn.network_error.disconnect(on_auth_request_txn_error);
282
debug("EVENT: OAuth authentication request transaction completed; response = '%s'",
285
do_parse_token_info_from_auth_request(txn.get_response());
288
private void on_auth_request_txn_error(Publishing.RESTSupport.Transaction txn,
289
Spit.Publishing.PublishingError err) {
290
txn.completed.disconnect(on_auth_request_txn_completed);
291
txn.network_error.disconnect(on_auth_request_txn_error);
296
debug("EVENT: OAuth authentication request transaction caused a network error");
297
host.post_error(err);
301
private void do_parse_token_info_from_auth_request(string response) {
302
debug("ACTION: parsing authorization request response '%s' into token and secret", response);
304
string? oauth_token = null;
305
string? oauth_token_secret = null;
307
string[] key_value_pairs = response.split("&");
308
foreach (string pair in key_value_pairs) {
309
string[] split_pair = pair.split("=");
311
if (split_pair.length != 2)
312
host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
313
_t("'%s' isn't a valid response to an OAuth authentication request")));
315
if (split_pair[0] == "oauth_token")
316
oauth_token = split_pair[1];
317
else if (split_pair[0] == "oauth_token_secret")
318
oauth_token_secret = split_pair[1];
321
if (oauth_token == null || oauth_token_secret == null)
322
host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
323
_t("'%s' isn't a valid response to an OAuth authentication request")));
325
session.set_access_phase_credentials(oauth_token, oauth_token_secret);
330
private void on_session_authenticated() {
334
debug("EVENT: a fully authenticated session has become available");
335
set_persistent_access_phase_token(session.get_access_phase_token());
336
set_persistent_access_phase_token_secret(session.get_access_phase_token_secret());
341
private void do_get_blogs() {
342
debug("ACTION: obtain all blogs of the tumblr user");
343
UserInfoFetchTransaction txn = new UserInfoFetchTransaction(session);
344
txn.completed.connect(on_info_request_txn_completed);
345
txn.network_error.connect(on_info_request_txn_error);
349
} catch (Spit.Publishing.PublishingError err) {
350
host.post_error(err);
357
private void on_info_request_txn_completed(Publishing.RESTSupport.Transaction txn) {
358
txn.completed.disconnect(on_info_request_txn_completed);
359
txn.network_error.disconnect(on_info_request_txn_error);
364
debug("EVENT: user info request transaction completed; response = '%s'",
366
do_parse_token_info_from_user_request(txn.get_response());
367
do_show_publishing_options_pane();
371
private void do_parse_token_info_from_user_request(string response) {
372
debug("ACTION: parsing info request response '%s' into list of available blogs", response);
374
var parser = new Json.Parser();
375
parser.load_from_data (response, -1);
376
var root_object = parser.get_root().get_object();
377
this.username = root_object.get_object_member("response").get_object_member("user").get_string_member ("name");
378
debug("Got user name: %s",username);
379
foreach (var blognode in root_object.get_object_member("response").get_object_member("user").get_array_member("blogs").get_elements ()) {
380
var blog = blognode.get_object ();
381
string name = blog.get_string_member ("name");
382
string url = blog.get_string_member ("url").replace("http://","").replace("/","");
383
debug("Got blog name: %s and url: %s", name, url);
384
this.blogs += new BlogEntry(name,url);
386
} catch (Error err) {
387
host.post_error(err);
391
private void on_info_request_txn_error(Publishing.RESTSupport.Transaction txn,
392
Spit.Publishing.PublishingError err) {
393
txn.completed.disconnect(on_info_request_txn_completed);
394
txn.network_error.disconnect(on_info_request_txn_error);
399
session.deauthenticate();
400
invalidate_persistent_session();
401
debug("EVENT: user info request transaction caused a network error");
402
host.post_error(err);
405
private void do_show_publishing_options_pane() {
406
debug("ACTION: displaying publishing options pane");
407
host.set_service_locked(false);
408
PublishingOptionsPane publishing_options_pane =
409
new PublishingOptionsPane(this, host.get_publishable_media_type(), this.sizes, this.blogs, this.username);
410
publishing_options_pane.publish.connect(on_publishing_options_pane_publish);
411
publishing_options_pane.logout.connect(on_publishing_options_pane_logout);
412
host.install_dialog_pane(publishing_options_pane);
417
private void on_publishing_options_pane_publish() {
418
publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish);
419
publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout);
424
debug("EVENT: user clicked the 'Publish' button in the publishing options pane");
428
private void on_publishing_options_pane_logout() {
429
publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish);
430
publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout);
435
debug("EVENT: user clicked the 'Logout' button in the publishing options pane");
440
public static int tumblr_date_time_compare_func(Spit.Publishing.Publishable a,
441
Spit.Publishing.Publishable b) {
442
return a.get_exposure_date_time().compare(b.get_exposure_date_time());
445
private void do_publish() {
446
debug("ACTION: uploading media items to remote server.");
448
host.set_service_locked(true);
450
progress_reporter = host.serialize_publishables(sizes[get_persistent_default_size()].size);
452
// Serialization is a long and potentially cancellable operation, so before we use
453
// the publishables, make sure that the publishing interaction is still running. If it
454
// isn't the publishing environment may be partially torn down so do a short-circuit
459
// Sort publishables in reverse-chronological order.
460
Spit.Publishing.Publishable[] publishables = host.get_publishables();
461
Gee.ArrayList<Spit.Publishing.Publishable> sorted_list =
462
new Gee.ArrayList<Spit.Publishing.Publishable>();
463
foreach (Spit.Publishing.Publishable p in publishables) {
464
debug("ACTION: add publishable");
467
sorted_list.sort((CompareFunc) tumblr_date_time_compare_func);
468
string blog_url = this.blogs[get_persistent_default_blog()].url;
470
Uploader uploader = new Uploader(session, sorted_list.to_array(),blog_url);
471
uploader.upload_complete.connect(on_upload_complete);
472
uploader.upload_error.connect(on_upload_error);
473
uploader.upload(on_upload_status_updated);
476
private void do_show_success_pane() {
477
debug("ACTION: showing success pane.");
479
host.set_service_locked(false);
480
host.install_success_pane();
484
private void on_upload_status_updated(int file_number, double completed_fraction) {
488
debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction);
490
assert(progress_reporter != null);
492
progress_reporter(file_number, completed_fraction);
495
private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader,
500
debug("EVENT: uploader reports upload complete; %d items published.", num_published);
502
uploader.upload_complete.disconnect(on_upload_complete);
503
uploader.upload_error.disconnect(on_upload_error);
505
do_show_success_pane();
508
private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader,
509
Spit.Publishing.PublishingError err) {
513
debug("EVENT: uploader reports upload error = '%s'.", err.message);
515
uploader.upload_complete.disconnect(on_upload_complete);
516
uploader.upload_error.disconnect(on_upload_error);
518
host.post_error(err);
522
private void do_logout() {
523
debug("ACTION: logging user out, deauthenticating session, and erasing stored credentials");
525
session.deauthenticate();
526
invalidate_persistent_session();
533
public void attempt_start() {
537
debug("TumblrPublisher: starting interaction.");
540
if (is_persistent_session_valid()) {
541
debug("attempt start: a persistent session is available; using it");
543
session.authenticate_from_persistent_credentials(get_persistent_access_phase_token(),
544
get_persistent_access_phase_token_secret());
546
debug("attempt start: no persistent session available; showing login welcome pane");
548
do_show_authentication_pane();
552
public void start() {
557
error(_t("TumblrPublisher: start( ): can't start; this publisher is not restartable."));
559
debug("TumblrPublisher: starting interaction.");
565
debug("TumblrPublisher: stop( ) invoked.");
567
// if (session != null)
568
// session.stop_transactions();
577
* The authentication pane used when asking service URL, user name and password
580
internal class AuthenticationPane : Spit.Publishing.DialogPane, Object {
585
private static string INTRO_MESSAGE = _t("Enter the username and password associated with your Tumblr account.");
586
private static string FAILED_RETRY_USER_MESSAGE = _t("Username and/or password invalid. Please try again");
588
private Gtk.Box pane_widget = null;
589
private Gtk.Builder builder;
590
private Gtk.Entry username_entry;
591
private Gtk.Entry password_entry;
592
private Gtk.Button login_button;
594
public signal void login(string user, string password);
596
public AuthenticationPane(TumblrPublisher publisher, Mode mode = Mode.INTRO) {
597
this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
599
File ui_file = publisher.get_host().get_module_file().get_parent().
600
get_child("tumblr_authentication_pane.glade");
603
builder = new Gtk.Builder();
604
builder.add_from_file(ui_file.get_path());
605
builder.connect_signals(null);
606
Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment;
608
Gtk.Label message_label = builder.get_object("message_label") as Gtk.Label;
611
message_label.set_text(INTRO_MESSAGE);
614
case Mode.FAILED_RETRY_USER:
615
message_label.set_markup("<b>%s</b>\n\n%s".printf(_t(
616
"Invalid User Name or Password"), FAILED_RETRY_USER_MESSAGE));
620
username_entry = builder.get_object ("username_entry") as Gtk.Entry;
622
password_entry = builder.get_object ("password_entry") as Gtk.Entry;
626
login_button = builder.get_object("login_button") as Gtk.Button;
628
username_entry.changed.connect(on_user_changed);
629
password_entry.changed.connect(on_password_changed);
630
login_button.clicked.connect(on_login_button_clicked);
632
align.reparent(pane_widget);
633
publisher.get_host().set_dialog_default_widget(login_button);
635
warning(_t("Could not load UI: %s"), e.message);
639
public Gtk.Widget get_default_widget() {
643
private void on_login_button_clicked() {
644
login(username_entry.get_text(),
645
password_entry.get_text());
649
private void on_user_changed() {
650
update_login_button_sensitivity();
653
private void on_password_changed() {
654
update_login_button_sensitivity();
657
private void update_login_button_sensitivity() {
658
login_button.set_sensitive(
659
!is_string_empty(username_entry.get_text()) &&
660
!is_string_empty(password_entry.get_text())
664
public Gtk.Widget get_widget() {
668
public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
669
return Spit.Publishing.DialogPane.GeometryOptions.NONE;
672
public void on_pane_installed() {
673
username_entry.grab_focus();
674
password_entry.set_activates_default(true);
675
login_button.can_default = true;
676
update_login_button_sensitivity();
679
public void on_pane_uninstalled() {
685
* The publishing options pane.
689
internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object {
693
private Gtk.Builder builder;
694
private Gtk.Box pane_widget = null;
695
private Gtk.Label upload_info_label = null;
696
private Gtk.Label size_label = null;
697
private Gtk.Label blog_label = null;
698
private Gtk.Button logout_button = null;
699
private Gtk.Button publish_button = null;
700
private Gtk.ComboBoxText size_combo = null;
701
private Gtk.ComboBoxText blog_combo = null;
702
private SizeEntry[] sizes = null;
703
private BlogEntry[] blogs = null;
704
private string username = "";
705
private TumblrPublisher publisher = null;
706
private Spit.Publishing.Publisher.MediaType media_type;
708
public signal void publish();
709
public signal void logout();
711
public PublishingOptionsPane(TumblrPublisher publisher, Spit.Publishing.Publisher.MediaType media_type, SizeEntry[] sizes, BlogEntry[] blogs, string username) {
713
this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
714
this.username = username;
715
this.publisher = publisher;
716
this.media_type = media_type;
719
File ui_file = publisher.get_host().get_module_file().get_parent().
720
get_child("tumblr_publishing_options_pane.glade");
723
builder = new Gtk.Builder();
724
builder.add_from_file(ui_file.get_path());
725
builder.connect_signals(null);
727
// pull in the necessary widgets from the glade file
728
pane_widget = (Gtk.Box) this.builder.get_object("tumblr_pane");
729
upload_info_label = (Gtk.Label) this.builder.get_object("upload_info_label");
730
logout_button = (Gtk.Button) this.builder.get_object("logout_button");
731
publish_button = (Gtk.Button) this.builder.get_object("publish_button");
732
size_combo = (Gtk.ComboBoxText) this.builder.get_object("size_combo");
733
size_label = (Gtk.Label) this.builder.get_object("size_label");
734
blog_combo = (Gtk.ComboBoxText) this.builder.get_object("blog_combo");
735
blog_label = (Gtk.Label) this.builder.get_object("blog_label");
738
string upload_label_text = _t("You are logged into Tumblr as %s.\n\n").printf(this.username);
739
upload_info_label.set_label(upload_label_text);
741
populate_blog_combo();
742
blog_combo.changed.connect(on_blog_changed);
744
if ((media_type != Spit.Publishing.Publisher.MediaType.VIDEO)) {
745
populate_size_combo();
746
size_combo.changed.connect(on_size_changed);
748
// publishing -only- video - don't let the user manipulate the photo size choices.
749
size_combo.set_sensitive(false);
750
size_label.set_sensitive(false);
753
logout_button.clicked.connect(on_logout_clicked);
754
publish_button.clicked.connect(on_publish_clicked);
756
warning(_t("Could not load UI: %s"), e.message);
764
private void on_logout_clicked() {
768
private void on_publish_clicked() {
775
private void populate_blog_combo() {
777
foreach (BlogEntry b in blogs)
778
blog_combo.append_text(b.blog);
779
blog_combo.set_active(publisher.get_persistent_default_blog());
783
private void on_blog_changed() {
784
publisher.set_persistent_default_blog(blog_combo.get_active());
787
private void populate_size_combo() {
789
foreach (SizeEntry e in sizes)
790
size_combo.append_text(e.title);
791
size_combo.set_active(publisher.get_persistent_default_size());
795
private void on_size_changed() {
796
publisher.set_persistent_default_size(size_combo.get_active());
800
protected void notify_publish() {
804
protected void notify_logout() {
808
public Gtk.Widget get_widget() {
812
public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
813
return Spit.Publishing.DialogPane.GeometryOptions.NONE;
816
public void on_pane_installed() {
817
publish.connect(notify_publish);
818
logout.connect(notify_logout);
821
public void on_pane_uninstalled() {
822
publish.disconnect(notify_publish);
823
logout.disconnect(notify_logout);
828
// REST support classes
829
internal class Transaction : Publishing.RESTSupport.Transaction {
830
public Transaction(Session session, Publishing.RESTSupport.HttpMethod method =
831
Publishing.RESTSupport.HttpMethod.POST) {
832
base(session, method);
836
public Transaction.with_uri(Session session, string uri,
837
Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.POST) {
838
base.with_endpoint_url(session, uri, method);
840
add_argument("oauth_nonce", session.get_oauth_nonce());
841
add_argument("oauth_signature_method", "HMAC-SHA1");
842
add_argument("oauth_version", "1.0");
843
add_argument("oauth_timestamp", session.get_oauth_timestamp());
844
add_argument("oauth_consumer_key", API_KEY);
845
if (session.get_access_phase_token() != null) {
846
add_argument("oauth_token", session.get_access_phase_token());
850
public override void execute() throws Spit.Publishing.PublishingError {
851
((Session) get_parent_session()).sign_transaction(this);
859
internal class AccessTokenFetchTransaction : Transaction {
860
public AccessTokenFetchTransaction(Session session, string username, string password) {
861
base.with_uri(session, "https://www.tumblr.com/oauth/access_token",
862
Publishing.RESTSupport.HttpMethod.POST);
863
add_argument("x_auth_username", Soup.URI.encode(username, ENCODE_RFC_3986_EXTRA));
864
add_argument("x_auth_password", password);
865
add_argument("x_auth_mode", "client_auth");
869
internal class UserInfoFetchTransaction : Transaction {
870
public UserInfoFetchTransaction(Session session) {
871
base.with_uri(session, "http://api.tumblr.com/v2/user/info",
872
Publishing.RESTSupport.HttpMethod.POST);
877
internal class UploadTransaction : Publishing.RESTSupport.UploadTransaction {
878
private Session session;
879
private Publishing.RESTSupport.Argument[] auth_header_fields;
882
//Workaround for Soup.URI.encode() to support binary data (i.e. string with \0)
883
private string encode( uint8[] data ){
884
var s = new StringBuilder();
885
char[] bytes = new char[2];
887
foreach( var byte in data )
892
bytes[0] = (char)byte;
893
s.append( Soup.URI.encode((string) bytes, ENCODE_RFC_3986_EXTRA) );
900
public UploadTransaction(Session session,Spit.Publishing.Publishable publishable, string blog_url) {
901
debug("Init upload transaction");
902
base.with_endpoint_url(session, publishable,"http://api.tumblr.com/v2/blog/%s/post".printf(blog_url) );
903
this.session = session;
909
public void add_authorization_header_field(string key, string value) {
910
auth_header_fields += new Publishing.RESTSupport.Argument(key, value);
913
public Publishing.RESTSupport.Argument[] get_authorization_header_fields() {
914
return auth_header_fields;
917
public string get_authorization_header_string() {
918
string result = "OAuth ";
920
for (int i = 0; i < auth_header_fields.length; i++) {
921
result += auth_header_fields[i].key;
923
result += ("\"" + auth_header_fields[i].value + "\"");
925
if (i < auth_header_fields.length - 1)
932
public override void execute() throws Spit.Publishing.PublishingError {
933
add_authorization_header_field("oauth_nonce", session.get_oauth_nonce());
934
add_authorization_header_field("oauth_signature_method", "HMAC-SHA1");
935
add_authorization_header_field("oauth_version", "1.0");
936
add_authorization_header_field("oauth_timestamp", session.get_oauth_timestamp());
937
add_authorization_header_field("oauth_consumer_key", API_KEY);
938
add_authorization_header_field("oauth_token", session.get_access_phase_token());
942
size_t payload_length;
944
FileUtils.get_contents(base.publishable.get_serialized_file().get_path(), out payload,
947
string reqdata = this.encode(payload.data[0:payload_length]);
951
add_argument("data[0]", reqdata);
952
add_argument("type", "photo");
953
string[] keywords = base.publishable.get_publishing_keywords();
955
if (keywords != null) {
956
foreach (string tag in keywords) {
957
if (!is_string_empty(tags)) {
963
add_argument("tags", Soup.URI.encode(tags, ENCODE_RFC_3986_EXTRA));
965
} catch (FileError e) {
966
throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(
967
_t("A temporary file needed for publishing is unavailable"));
972
session.sign_transaction(this);
974
string authorization_header = get_authorization_header_string();
976
debug("executing upload transaction: authorization header string = '%s'",
977
authorization_header);
978
add_header("Authorization", authorization_header);
980
Publishing.RESTSupport.Argument[] request_arguments = get_arguments();
981
assert(request_arguments.length > 0);
983
string request_data = "";
984
for (int i = 0; i < request_arguments.length; i++) {
985
request_data += (request_arguments[i].key + "=" + request_arguments[i].value);
986
if (i < request_arguments.length - 1)
989
Soup.Message outbound_message = new Soup.Message( "POST", get_endpoint_url());
990
outbound_message.set_request("application/x-www-form-urlencoded", Soup.MemoryUse.COPY, request_data.data);
992
// TODO: there must be a better way to iterate over a map
993
Gee.MapIterator<string, string> i = base.message_headers.map_iterator();
994
bool cont = i.first();
996
outbound_message.request_headers.append(i.get_key(), i.get_value());
999
set_message(outbound_message);
1001
set_is_executed(true);
1009
internal class Uploader : Publishing.RESTSupport.BatchUploader {
1010
private string blog_url = "";
1011
public Uploader(Session session, Spit.Publishing.Publishable[] publishables, string blog_url) {
1012
base(session, publishables);
1013
this.blog_url=blog_url;
1018
protected override Publishing.RESTSupport.Transaction create_transaction(
1019
Spit.Publishing.Publishable publishable) {
1020
debug("Create upload transaction");
1021
return new UploadTransaction((Session) get_session(), get_current_publishable(), this.blog_url);
1027
* Session class that keeps track of the authentication status and of the
1028
* user token tumblr.
1030
internal class Session : Publishing.RESTSupport.Session {
1031
private string? access_phase_token = null;
1032
private string? access_phase_token_secret = null;
1039
public override bool is_authenticated() {
1040
return (access_phase_token != null && access_phase_token_secret != null);
1043
public void authenticate_from_persistent_credentials(string token, string secret) {
1044
this.access_phase_token = token;
1045
this.access_phase_token_secret = secret;
1051
public void deauthenticate() {
1052
access_phase_token = null;
1053
access_phase_token_secret = null;
1056
public void sign_transaction(Publishing.RESTSupport.Transaction txn) {
1057
string http_method = txn.get_method().to_string();
1059
debug("signing transaction with parameters:");
1060
debug("HTTP method = " + http_method);
1061
string? signing_key = null;
1062
if (access_phase_token_secret != null) {
1063
debug("access phase token secret available; using it as signing key");
1065
signing_key = API_SECRET + "&" + this.get_access_phase_token_secret();
1067
debug("Access phase token secret not available; using API " +
1068
"key as signing key");
1070
signing_key = API_SECRET + "&";
1074
Publishing.RESTSupport.Argument[] base_string_arguments = txn.get_arguments();
1076
UploadTransaction? upload_txn = txn as UploadTransaction;
1077
if (upload_txn != null) {
1078
debug("this transaction is an UploadTransaction; including Authorization header " +
1079
"fields in signature base string");
1081
Publishing.RESTSupport.Argument[] auth_header_args =
1082
upload_txn.get_authorization_header_fields();
1084
foreach (Publishing.RESTSupport.Argument arg in auth_header_args)
1085
base_string_arguments += arg;
1088
Publishing.RESTSupport.Argument[] sorted_args =
1089
Publishing.RESTSupport.Argument.sort(base_string_arguments);
1091
string arguments_string = "";
1092
for (int i = 0; i < sorted_args.length; i++) {
1093
arguments_string += (sorted_args[i].key + "=" + sorted_args[i].value);
1094
if (i < sorted_args.length - 1)
1095
arguments_string += "&";
1099
string signature_base_string = http_method + "&" + Soup.URI.encode(
1100
txn.get_endpoint_url(), ENCODE_RFC_3986_EXTRA) + "&" +
1101
Soup.URI.encode(arguments_string, ENCODE_RFC_3986_EXTRA);
1103
debug("signature base string = '%s'", signature_base_string);
1104
debug("signing key = '%s'", signing_key);
1106
// compute the signature
1107
string signature = hmac_sha1(signing_key, signature_base_string);
1108
debug("signature = '%s'", signature);
1109
signature = Soup.URI.encode(signature, ENCODE_RFC_3986_EXTRA);
1111
debug("signature after RFC encode = '%s'", signature);
1113
if (upload_txn != null)
1114
upload_txn.add_authorization_header_field("oauth_signature", signature);
1116
txn.add_argument("oauth_signature", signature);
1121
public void set_access_phase_credentials(string token, string secret) {
1122
this.access_phase_token = token;
1123
this.access_phase_token_secret = secret;
1129
public string get_access_phase_token() {
1130
return access_phase_token;
1134
public string get_access_phase_token_secret() {
1135
return access_phase_token_secret;
1138
public string get_oauth_nonce() {
1139
TimeVal currtime = TimeVal();
1140
currtime.get_current_time();
1142
return Checksum.compute_for_string(ChecksumType.MD5, currtime.tv_sec.to_string() +
1143
currtime.tv_usec.to_string());
1146
public string get_oauth_timestamp() {
1147
return GLib.get_real_time().to_string().substring(0, 10);
1153
} //class TumblrPublisher
1155
} //namespace Publishing.Tumblr