1/*
2* Copyright (c) 2009-2013 Yorba Foundation
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
20extern Soup.Message soup_form_request_new_from_multipart (string uri, Soup.Multipart multipart);
21
22namespace Publishing.RESTSupport {
23
24public abstract class Session {
25    private string? endpoint_url = null;
26    private Soup.Session soup_session = null;
27    private bool transactions_stopped = false;
28
29    public signal void wire_message_unqueued (Soup.Message message);
30    public signal void authenticated ();
31    public signal void authentication_failed (Spit.Publishing.PublishingError err);
32
33    protected Session (string? endpoint_url = null) {
34        this.endpoint_url = endpoint_url;
35        soup_session = new Soup.Session ();
36    }
37
38    protected void notify_wire_message_unqueued (Soup.Message message) {
39        wire_message_unqueued (message);
40    }
41
42    protected void notify_authenticated () {
43        authenticated ();
44    }
45
46    protected void notify_authentication_failed (Spit.Publishing.PublishingError err) {
47        authentication_failed (err);
48    }
49
50    public abstract bool is_authenticated ();
51
52    public string? get_endpoint_url () {
53        return endpoint_url;
54    }
55
56    public void stop_transactions () {
57        transactions_stopped = true;
58        soup_session.abort ();
59    }
60
61    public bool are_transactions_stopped () {
62        return transactions_stopped;
63    }
64
65    public void send_wire_message (Soup.Message message) {
66        if (are_transactions_stopped ())
67            return;
68
69        soup_session.request_unqueued.connect (notify_wire_message_unqueued);
70        soup_session.send_message (message);
71
72        soup_session.request_unqueued.disconnect (notify_wire_message_unqueued);
73    }
74}
75
76public enum HttpMethod {
77    GET,
78    POST,
79    PUT;
80
81    public string to_string () {
82        switch (this) {
83        case HttpMethod.GET:
84            return "GET";
85
86        case HttpMethod.PUT:
87            return "PUT";
88
89        case HttpMethod.POST:
90            return "POST";
91
92        default:
93            error ("unrecognized HTTP method enumeration value");
94        }
95    }
96
97    public static HttpMethod from_string (string str) {
98        if (str == "GET") {
99            return HttpMethod.GET;
100        } else if (str == "PUT") {
101            return HttpMethod.PUT;
102        } else if (str == "POST") {
103            return HttpMethod.POST;
104        } else {
105            error ("unrecognized HTTP method name: %s", str);
106        }
107    }
108}
109
110public class Argument {
111    public string key;
112    public string value;
113
114    public Argument (string key, string value) {
115        this.key = key;
116        this.value = value;
117    }
118
119    public static int compare (Argument arg1, Argument arg2) {
120        return strcmp (arg1.key, arg2.key);
121    }
122
123    public static Argument[] sort (Argument[] input_array) {
124        FixedTreeSet<Argument> sorted_args = new FixedTreeSet<Argument> (Argument.compare);
125
126        foreach (Argument arg in input_array)
127            sorted_args.add (arg);
128
129        return sorted_args.to_array ();
130    }
131}
132
133public class Transaction {
134    private Argument[] arguments;
135    private bool is_executed = false;
136    private weak Session parent_session = null;
137    private Soup.Message message = null;
138    private int bytes_written = 0;
139    private Spit.Publishing.PublishingError? err = null;
140    private string? endpoint_url = null;
141    private bool use_custom_payload;
142
143    public signal void chunk_transmitted (int bytes_written_so_far, int total_bytes);
144    public signal void network_error (Spit.Publishing.PublishingError err);
145    public signal void completed ();
146
147    public Transaction (Session parent_session, HttpMethod method = HttpMethod.POST) {
148        // if our creator doesn't specify an endpoint url by using the Transaction.with_endpoint_url
149        // constructor, then our parent session must have a non-null endpoint url
150        assert (parent_session.get_endpoint_url () != null);
151
152        this.parent_session = parent_session;
153
154        message = new Soup.Message (method.to_string (), parent_session.get_endpoint_url ());
155        message.wrote_body_data.connect (on_wrote_body_data);
156    }
157
158    public Transaction.with_endpoint_url (Session parent_session, string endpoint_url,
159                                          HttpMethod method = HttpMethod.POST) {
160        this.parent_session = parent_session;
161        this.endpoint_url = endpoint_url;
162        message = new Soup.Message (method.to_string (), endpoint_url);
163    }
164
165    private void on_wrote_body_data (Soup.Buffer written_data) {
166        bytes_written += (int) written_data.length;
167        chunk_transmitted (bytes_written, (int) message.request_body.length);
168    }
169
170    private void on_message_unqueued (Soup.Message message) {
171        if (this.message != message)
172            return;
173
174        try {
175            check_response (message);
176        } catch (Spit.Publishing.PublishingError err) {
177            warning ("Publishing error: %s", err.message);
178            warning ("response validation failed. bad response = '%s'.", get_response ());
179            this.err = err;
180        }
181    }
182
183    protected void check_response (Soup.Message message) throws Spit.Publishing.PublishingError {
184        switch (message.status_code) {
185        case Soup.Status.OK:
186        case Soup.Status.CREATED: // HTTP code 201 (CREATED) signals that a new
187            // resource was created in response to a PUT or POST
188            break;
189
190        case Soup.Status.CANT_RESOLVE:
191        case Soup.Status.CANT_RESOLVE_PROXY:
192            throw new Spit.Publishing.PublishingError.NO_ANSWER ("Unable to resolve %s (error code %u)",
193            get_endpoint_url (), message.status_code);
194
195        case Soup.Status.CANT_CONNECT:
196        case Soup.Status.CANT_CONNECT_PROXY:
197            throw new Spit.Publishing.PublishingError.NO_ANSWER ("Unable to connect to %s (error code %u)",
198            get_endpoint_url (), message.status_code);
199
200        default:
201            // status codes below 100 are used by Soup, 100 and above are defined HTTP codes
202            if (message.status_code >= 100) {
203                throw new Spit.Publishing.PublishingError.NO_ANSWER ("Service %s returned HTTP status code %u %s",
204                get_endpoint_url (), message.status_code, message.reason_phrase);
205            } else {
206                throw new Spit.Publishing.PublishingError.NO_ANSWER ("Failure communicating with %s (error code %u)",
207                        get_endpoint_url (), message.status_code);
208            }
209        }
210
211        // All valid communication involves body data in the response
212        if (message.response_body.data == null || message.response_body.data.length == 0)
213            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("No response data from %s",
214                    get_endpoint_url ());
215    }
216
217    public Argument[] get_arguments () {
218        return arguments;
219    }
220
221    public Argument[] get_sorted_arguments () {
222        return Argument.sort (get_arguments ());
223    }
224
225    protected void set_is_executed (bool is_executed) {
226        this.is_executed = is_executed;
227    }
228
229    protected void send () throws Spit.Publishing.PublishingError {
230        parent_session.wire_message_unqueued.connect (on_message_unqueued);
231        message.wrote_body_data.connect (on_wrote_body_data);
232        parent_session.send_wire_message (message);
233
234        parent_session.wire_message_unqueued.disconnect (on_message_unqueued);
235        message.wrote_body_data.disconnect (on_wrote_body_data);
236
237        if (err != null)
238            network_error (err);
239        else
240            completed ();
241
242        if (err != null)
243            throw err;
244    }
245
246    public HttpMethod get_method () {
247        return HttpMethod.from_string (message.method);
248    }
249
250    protected virtual void add_header (string key, string value) {
251        message.request_headers.append (key, value);
252    }
253
254    // set custom_payload to null to have this transaction send the default payload of
255    // key-value pairs appended through add_argument(...) (this is how most REST requests work).
256    // To send a payload other than traditional key-value pairs (such as an XML document or a JPEG
257    // image) to the endpoint, set the custom_payload parameter to a non-null value. If the
258    // custom_payload you specify is text data, then it's null terminated, and its length is just
259    // custom_payload.length, so you don't have to pass in a payload_length parameter in this case.
260    // If, however, custom_payload is binary data (such as a JEPG), then the caller must set
261    // payload_length to the byte length of the custom_payload buffer
262    protected void set_custom_payload (string? custom_payload, string payload_content_type,
263                                       ulong payload_length = 0) {
264        assert (get_method () != HttpMethod.GET); // GET messages don't have payloads
265
266        if (custom_payload == null) {
267            use_custom_payload = false;
268            return;
269        }
270
271        ulong length = (payload_length > 0) ? payload_length : custom_payload.length;
272        message.set_request (payload_content_type, Soup.MemoryUse.COPY, custom_payload.data[0:length]);
273
274        use_custom_payload = true;
275    }
276
277    // When writing a specialized transaction subclass you should rarely need to
278    // call this method. In general, it's better to leave the underlying Soup message
279    // alone and let the Transaction class manage it for you. You should only need
280    // to install a new message if your subclass has radically different behavior from
281    // normal Transactions -- like multipart encoding.
282    protected void set_message (Soup.Message message) {
283        this.message = message;
284    }
285
286    public bool get_is_executed () {
287        return is_executed;
288    }
289
290    public uint get_status_code () {
291        assert (get_is_executed ());
292        return message.status_code;
293    }
294
295    public virtual void execute () throws Spit.Publishing.PublishingError {
296        // if a custom payload is being used, we don't need to peform the tasks that are necessary
297        // to prepare a traditional key-value pair REST request; Instead (since we don't
298        // know anything about the custom payload), we just put it on the wire and return
299        if (use_custom_payload) {
300            is_executed = true;
301            send ();
302
303            return;
304        }
305
306        //  REST POST requests must transmit at least one argument
307        if (get_method () == HttpMethod.POST)
308            assert (arguments.length > 0);
309
310        // concatenate the REST arguments array into an HTTP formdata string
311        string formdata_string = "";
312        for (int i = 0; i < arguments.length; i++) {
313            formdata_string += ("%s=%s".printf (arguments[i].key, arguments[i].value));
314            if (i < arguments.length - 1)
315                formdata_string += "&";
316        }
317
318        // for GET requests with arguments, append the formdata string to the endpoint url after a
319        // query divider ('?') -- but make sure to save the old (caller-specified) endpoint URL
320        // and restore it after the GET so that the underlying Soup message remains consistent
321        string old_url = null;
322        string url_with_query = null;
323        if (get_method () == HttpMethod.GET && arguments.length > 0) {
324            old_url = message.get_uri ().to_string (false);
325            url_with_query = get_endpoint_url () + "?" + formdata_string;
326            message.set_uri (new Soup.URI (url_with_query));
327        } else {
328            message.set_request ("application/x-www-form-urlencoded", Soup.MemoryUse.COPY,
329            formdata_string.data);
330        }
331
332        is_executed = true;
333
334        try {
335            debug ("sending message to URI = '%s'", message.get_uri ().to_string (false));
336            send ();
337        } finally {
338            // if old_url is non-null, then restore it
339            if (old_url != null)
340                message.set_uri (new Soup.URI (old_url));
341        }
342    }
343
344    public string get_response () {
345        assert (get_is_executed ());
346        return (string) message.response_body.data;
347    }
348
349    public unowned Soup.MessageHeaders get_response_headers () {
350        assert (get_is_executed ());
351        return message.response_headers;
352    }
353
354    public void add_argument (string name, string value) {
355        arguments += new Argument (name, value);
356    }
357
358    public string? get_endpoint_url () {
359        return (endpoint_url != null) ? endpoint_url : parent_session.get_endpoint_url ();
360    }
361
362    public Session get_parent_session () {
363        return parent_session;
364    }
365}
366
367public class UploadTransaction : Transaction {
368    protected GLib.HashTable<string, string> binary_disposition_table = null;
369    protected Spit.Publishing.Publishable publishable = null;
370    protected string mime_type;
371    protected Gee.HashMap<string, string> message_headers = null;
372
373    public UploadTransaction (Session session, Spit.Publishing.Publishable publishable) {
374        base (session);
375        this.publishable = publishable;
376        this.mime_type = media_type_to_mime_type (publishable.get_media_type ());
377
378        binary_disposition_table = create_default_binary_disposition_table ();
379
380        message_headers = new Gee.HashMap<string, string> ();
381    }
382
383    public UploadTransaction.with_endpoint_url (Session session,
384            Spit.Publishing.Publishable publishable, string endpoint_url) {
385        base.with_endpoint_url (session, endpoint_url);
386        this.publishable = publishable;
387        this.mime_type = media_type_to_mime_type (publishable.get_media_type ());
388
389        binary_disposition_table = create_default_binary_disposition_table ();
390
391        message_headers = new Gee.HashMap<string, string> ();
392    }
393
394    protected override void add_header (string key, string value) {
395        message_headers.set (key, value);
396    }
397
398    private static string media_type_to_mime_type (Spit.Publishing.Publisher.MediaType media_type) {
399        if (media_type == Spit.Publishing.Publisher.MediaType.PHOTO)
400            return "image/jpeg";
401        else if (media_type == Spit.Publishing.Publisher.MediaType.VIDEO)
402            return "video/mpeg";
403        else
404            error ("UploadTransaction: unknown media type %s.", media_type.to_string ());
405    }
406
407    private GLib.HashTable<string, string> create_default_binary_disposition_table () {
408        GLib.HashTable<string, string> result =
409            new GLib.HashTable<string, string> (GLib.str_hash, GLib.str_equal);
410
411        result.insert ("filename", Soup.URI.encode (publishable.get_serialized_file ().get_basename (),
412                       null));
413
414        return result;
415    }
416
417    protected void set_binary_disposition_table (GLib.HashTable<string, string> new_disp_table) {
418        binary_disposition_table = new_disp_table;
419    }
420
421    public override void execute () throws Spit.Publishing.PublishingError {
422        Argument[] request_arguments = get_arguments ();
423        assert (request_arguments.length > 0);
424
425        Soup.Multipart message_parts = new Soup.Multipart ("multipart/form-data");
426
427        foreach (Argument arg in request_arguments)
428            message_parts.append_form_string (arg.key, arg.value);
429
430        string payload;
431        size_t payload_length;
432        try {
433            FileUtils.get_contents (publishable.get_serialized_file ().get_path (), out payload,
434            out payload_length);
435        } catch (FileError e) {
436            throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR (
437                _ ("A temporary file needed for publishing is unavailable"));
438        }
439
440        int payload_part_num = message_parts.get_length ();
441
442        Soup.Buffer bindable_data = new Soup.Buffer (Soup.MemoryUse.COPY, payload.data[0:payload_length]);
443        message_parts.append_form_file ("", publishable.get_serialized_file ().get_path (), mime_type,
444        bindable_data);
445
446        unowned Soup.MessageHeaders image_part_header;
447        unowned Soup.Buffer image_part_body;
448        message_parts.get_part (payload_part_num, out image_part_header, out image_part_body);
449        image_part_header.set_content_disposition ("form-data", binary_disposition_table);
450
451        Soup.Message outbound_message =
452        soup_form_request_new_from_multipart (get_endpoint_url (), message_parts);
453        // TODO: there must be a better way to iterate over a map
454        Gee.MapIterator<string, string> i = message_headers.map_iterator ();
455        bool cont = i.next ();
456        while (cont) {
457            outbound_message.request_headers.append (i.get_key (), i.get_value ());
458            cont = i.next ();
459        }
460        set_message (outbound_message);
461
462        set_is_executed (true);
463        send ();
464    }
465}
466
467public class XmlDocument {
468    // Returns non-null string if an error condition is discovered in the XML (such as a well-known
469    // node).  The string is used when generating a PublishingError exception.  This delegate does
470    // not need to check for general-case malformed XML.
471    public delegate string? CheckForErrorResponse (XmlDocument doc);
472
473    private Xml.Doc *document;
474
475    private XmlDocument (Xml.Doc *doc) {
476        document = doc;
477    }
478
479    ~XmlDocument () {
480        delete document;
481    }
482
483    public Xml.Node *get_root_node () {
484        return document->get_root_element ();
485    }
486
487    public Xml.Node *get_named_child (Xml.Node *parent, string child_name)
488    throws Spit.Publishing.PublishingError {
489        Xml.Node *doc_node_iter = parent->children;
490
491        for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) {
492            if (doc_node_iter->name == child_name)
493                return doc_node_iter;
494        }
495
496        throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Can't find XML node %s",
497        child_name);
498    }
499
500    public string get_property_value (Xml.Node *node, string property_key)
501    throws Spit.Publishing.PublishingError {
502        string value_string = node->get_prop (property_key);
503        if (value_string == null)
504            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Can't find XML " +
505            "property %s on node %s", property_key, node->name);
506
507        return value_string;
508    }
509
510    public static XmlDocument parse_string (string? input_string,
511                                            CheckForErrorResponse check_for_error_response) throws Spit.Publishing.PublishingError {
512        if (input_string == null || input_string.length == 0)
513            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Empty XML string");
514
515        // Does this even start and end with the right characters?
516        if (!input_string.chug ().chomp ().has_prefix ("<") ||
517        !input_string.chug ().chomp ().has_suffix (">")) {
518            // Didn't start or end with a < or > and can't be parsed as XML - treat as malformed.
519            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Unable to parse XML " +
520            "document");
521        }
522
523        // Don't want blanks to be included as text nodes, and want the XML parser to tolerate
524        // tolerable XML
525        Xml.Doc *doc = Xml.Parser.read_memory (input_string, (int) input_string.length, null, null,
526        Xml.ParserOption.NOBLANKS | Xml.ParserOption.RECOVER);
527        if (doc == null)
528            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Unable to parse XML " +
529            "document");
530
531        // Since 'doc' is the top level, if it has no children, something is wrong
532        // with the XML; we cannot continue normally here.
533        if (doc->children == null) {
534            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE ("Unable to parse XML " +
535            "document");
536        }
537
538        XmlDocument rest_doc = new XmlDocument (doc);
539
540        string? result = check_for_error_response (rest_doc);
541        if (result != null)
542            throw new Spit.Publishing.PublishingError.SERVICE_ERROR ("%s", result);
543
544        return rest_doc;
545    }
546}
547
548/* Encoding strings in XML decimal encoding is a relatively esoteric operation. Most web services
549   prefer to have non-ASCII character entities encoded using "symbolic encoding," where common
550   entities are encoded in short, symbolic names (e.g. "ñ" -> &ntilde;). Picasa Web Albums,
551   however, doesn't like symbolic encoding, and instead wants non-ASCII entities encoded directly
552   as their Unicode code point numbers (e.g. "ñ" -> &241;). */
553public string decimal_entity_encode (string source) {
554    StringBuilder encoded_str_builder = new StringBuilder ();
555    string current_char = source;
556    while (true) {
557        int current_char_value = (int) (current_char.get_char_validated ());
558
559        // null character signals end of string
560        if (current_char_value < 1)
561            break;
562
563        // no need to escape ASCII characters except the ampersand, greater-than sign and less-than
564        // signs, which are special in the world of XML
565        if ((current_char_value < 128) && (current_char_value != '&') && (current_char_value != '<') &&
566                (current_char_value != '>'))
567            encoded_str_builder.append_unichar (current_char.get_char_validated ());
568        else
569            encoded_str_builder.append ("&#%d;".printf (current_char_value));
570
571        current_char = current_char.next_char ();
572    }
573
574    return encoded_str_builder.str;
575}
576
577internal abstract class BatchUploader {
578    private int current_file = 0;
579    private Spit.Publishing.Publishable[] publishables = null;
580    private Session session = null;
581    private unowned Spit.Publishing.ProgressCallback? status_updated = null;
582
583    public signal void upload_complete (int num_photos_published);
584    public signal void upload_error (Spit.Publishing.PublishingError err);
585
586    protected BatchUploader (Session session, Spit.Publishing.Publishable[] publishables) {
587        this.publishables = publishables;
588        this.session = session;
589    }
590
591    private void send_files () {
592        current_file = 0;
593        bool stop = false;
594        foreach (Spit.Publishing.Publishable publishable in publishables) {
595            GLib.File? file = publishable.get_serialized_file ();
596
597            // if the current publishable hasn't been serialized, then skip it
598            if (file == null) {
599                current_file++;
600                continue;
601            }
602
603            double fraction_complete = ((double) current_file) / publishables.length;
604            if (status_updated != null)
605                status_updated (current_file + 1, fraction_complete);
606
607            Transaction txn = create_transaction (publishables[current_file]);
608
609            txn.chunk_transmitted.connect (on_chunk_transmitted);
610
611            try {
612                txn.execute ();
613            } catch (Spit.Publishing.PublishingError err) {
614                upload_error (err);
615                stop = true;
616            }
617
618            txn.chunk_transmitted.disconnect (on_chunk_transmitted);
619
620            if (stop)
621                break;
622
623            current_file++;
624        }
625
626        if (!stop)
627            upload_complete (current_file);
628    }
629
630    private void on_chunk_transmitted (int bytes_written_so_far, int total_bytes) {
631        double file_span = 1.0 / publishables.length;
632        double this_file_fraction_complete = ((double) bytes_written_so_far) / total_bytes;
633        double fraction_complete = (current_file * file_span) + (this_file_fraction_complete *
634                                   file_span);
635
636        if (status_updated != null)
637            status_updated (current_file + 1, fraction_complete);
638    }
639
640    protected Session get_session () {
641        return session;
642    }
643
644    protected Spit.Publishing.Publishable get_current_publishable () {
645        return publishables[current_file];
646    }
647
648    protected abstract Transaction create_transaction (Spit.Publishing.Publishable publishable);
649
650    public void upload (Spit.Publishing.ProgressCallback? status_updated = null) {
651        this.status_updated = status_updated;
652
653        if (publishables.length > 0)
654            send_files ();
655    }
656}
657
658// Remove diacritics in a string, yielding ASCII.  If the given string is in
659// a character set not based on Latin letters (e.g. Cyrillic), the result
660// may be empty.
661public string asciify_string (string s) {
662    string t = s.normalize ();  // default normalization yields a maximally decomposed form
663
664    StringBuilder b = new StringBuilder ();
665    for (unowned string u = t; u.get_char () != 0 ; u = u.next_char ()) {
666        unichar c = u.get_char ();
667        if ((int) c < 128)
668            b.append_unichar (c);
669    }
670
671    return b.str;
672}
673
674/** @brief Work-around for a problem in libgee where a TreeSet can leak references when it
675 * goes out of scope; please see https://bugzilla.gnome.org/show_bug.cgi?id=695045 for more
676 * details. This class merely wraps it and adds a call to clear () to the destructor.
677 */
678public class FixedTreeSet<G> : Gee.TreeSet<G> {
679    public FixedTreeSet (owned CompareDataFunc<G>? comp_func = null) {
680        base ((owned) comp_func);
681    }
682
683    ~FixedTreeSet () {
684        clear ();
685    }
686}
687
688public abstract class GoogleSession : Session {
689    public abstract string get_user_name ();
690    public abstract string get_access_token ();
691    public abstract string get_refresh_token ();
692    public abstract void deauthenticate ();
693}
694
695public abstract class GooglePublisher : Object, Spit.Publishing.Publisher {
696    private const string OAUTH_CLIENT_ID = "1073902228337-gm4uf5etk25s0hnnm0g7uv2tm2bm1j0b.apps.googleusercontent.com";
697    private const string OAUTH_CLIENT_SECRET = "_kA4RZz72xqed4DqfO7xMmMN";
698
699    private class GoogleSessionImpl : GoogleSession {
700        public string? access_token;
701        public string? user_name;
702        public string? refresh_token;
703
704        public GoogleSessionImpl () {
705            this.access_token = null;
706            this.user_name = null;
707            this.refresh_token = null;
708        }
709
710        public override bool is_authenticated () {
711            return (access_token != null);
712        }
713
714        public override string get_user_name () {
715            assert (user_name != null);
716            return user_name;
717        }
718
719        public override string get_access_token () {
720            assert (is_authenticated ());
721            return access_token;
722        }
723
724        public override string get_refresh_token () {
725            assert (refresh_token != null);
726            return refresh_token;
727        }
728
729        public override void deauthenticate () {
730            access_token = null;
731            user_name = null;
732            refresh_token = null;
733        }
734    }
735
736    private class WebAuthenticationPane : Spit.Publishing.DialogPane, Object {
737        public static bool cache_dirty = false;
738
739        private WebKit.WebView webview;
740        private Gtk.Box pane_widget;
741        private Gtk.ScrolledWindow webview_frame;
742        private string auth_sequence_start_url;
743
744        public signal void authorized (string auth_code);
745
746        public WebAuthenticationPane (string auth_sequence_start_url) {
747            this.auth_sequence_start_url = auth_sequence_start_url;
748
749            pane_widget = new Gtk.Box (Gtk.Orientation.VERTICAL, 0);
750
751            webview_frame = new Gtk.ScrolledWindow (null, null);
752            webview_frame.set_shadow_type (Gtk.ShadowType.ETCHED_IN);
753            webview_frame.set_policy (Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
754            webview_frame.expand = true;
755
756            webview = new WebKit.WebView ();
757            webview.get_settings ().enable_plugins = false;
758            webview.load_changed.connect ((load_event) => {
759                if (load_event == WebKit.LoadEvent.STARTED) {
760                    on_load_started ();
761                } else if (load_event == WebKit.LoadEvent.FINISHED) {
762                    on_page_load ();
763                }
764            });
765
766            webview_frame.add (webview);
767            pane_widget.pack_start (webview_frame, true, true, 0);
768        }
769
770        public static bool is_cache_dirty () {
771            return cache_dirty;
772        }
773
774        private void on_page_load () {
775            pane_widget.get_window ().set_cursor (new Gdk.Cursor.for_display (Gdk.Display.get_default (), Gdk.CursorType.LEFT_PTR));
776
777            string page_title = webview.get_title ();
778            if (page_title.index_of ("state=connect") > 0) {
779                int auth_code_field_start = page_title.index_of ("code=");
780                if (auth_code_field_start < 0)
781                    return;
782
783                string auth_code =
784                    page_title.substring (auth_code_field_start + 5); // 5 = "code=".length
785
786                cache_dirty = true;
787
788                authorized (auth_code);
789            }
790        }
791
792        private void on_load_started () {
793            pane_widget.get_window ().set_cursor (new Gdk.Cursor.for_display (Gdk.Display.get_default (), Gdk.CursorType.WATCH));
794        }
795
796        public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry () {
797            return Spit.Publishing.DialogPane.GeometryOptions.NONE;
798        }
799
800        public Gtk.Widget get_widget () {
801            return pane_widget;
802        }
803
804        public void on_pane_installed () {
805            webview.load_uri (auth_sequence_start_url);
806        }
807
808        public void on_pane_uninstalled () {
809        }
810    }
811
812    private class GetAccessTokensTransaction : Publishing.RESTSupport.Transaction {
813        private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token";
814
815        public GetAccessTokensTransaction (Session session, string auth_code) {
816            base.with_endpoint_url (session, ENDPOINT_URL);
817
818            add_argument ("code", auth_code);
819            add_argument ("client_id", OAUTH_CLIENT_ID);
820            add_argument ("client_secret", OAUTH_CLIENT_SECRET);
821            add_argument ("redirect_uri", "urn:ietf:wg:oauth:2.0:oob");
822            add_argument ("grant_type", "authorization_code");
823        }
824    }
825
826    private class RefreshAccessTokenTransaction : Publishing.RESTSupport.Transaction {
827        private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token";
828
829        public RefreshAccessTokenTransaction (Session session) {
830            base.with_endpoint_url (session, ENDPOINT_URL);
831
832            add_argument ("client_id", OAUTH_CLIENT_ID);
833            add_argument ("client_secret", OAUTH_CLIENT_SECRET);
834            add_argument ("refresh_token", ((GoogleSession) session).get_refresh_token ());
835            add_argument ("grant_type", "refresh_token");
836        }
837    }
838
839    public class AuthenticatedTransaction : Publishing.RESTSupport.Transaction {
840        private AuthenticatedTransaction.with_endpoint_url (GoogleSession session,
841                string endpoint_url, Publishing.RESTSupport.HttpMethod method) {
842            base.with_endpoint_url (session, endpoint_url, method);
843        }
844
845        public AuthenticatedTransaction (GoogleSession session, string endpoint_url,
846                                         Publishing.RESTSupport.HttpMethod method) {
847            base.with_endpoint_url (session, endpoint_url, method);
848            assert (session.is_authenticated ());
849
850            add_header ("Authorization", "Bearer " + session.get_access_token ());
851        }
852    }
853
854    private class UsernameFetchTransaction : AuthenticatedTransaction {
855        private const string ENDPOINT_URL = "https://www.googleapis.com/oauth2/v1/userinfo";
856
857        public UsernameFetchTransaction (GoogleSession session) {
858            base (session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET);
859        }
860    }
861
862    private string scope;
863    private GoogleSessionImpl session;
864    private WebAuthenticationPane? web_auth_pane;
865    private weak Spit.Publishing.PluginHost host;
866    private weak Spit.Publishing.Service service;
867
868    protected GooglePublisher (Spit.Publishing.Service service, Spit.Publishing.PluginHost host,
869                               string scope) {
870        this.scope = scope;
871        this.session = new GoogleSessionImpl ();
872        this.service = service;
873        this.host = host;
874        this.web_auth_pane = null;
875    }
876
877    private void on_web_auth_pane_authorized (string auth_code) {
878        web_auth_pane.authorized.disconnect (on_web_auth_pane_authorized);
879
880        debug ("EVENT: user authorized scope %s with auth_code %s", scope, auth_code);
881
882        if (!is_running ())
883            return;
884
885        do_get_access_tokens (auth_code);
886    }
887
888    private void on_get_access_tokens_complete (Publishing.RESTSupport.Transaction txn) {
889        txn.completed.disconnect (on_get_access_tokens_complete);
890        txn.network_error.disconnect (on_get_access_tokens_error);
891
892        debug ("EVENT: network transaction to exchange authorization code for access tokens " +
893               "completed successfully.");
894
895        if (!is_running ())
896            return;
897
898        do_extract_tokens (txn.get_response ());
899    }
900
901    private void on_get_access_tokens_error (Publishing.RESTSupport.Transaction txn,
902            Spit.Publishing.PublishingError err) {
903        txn.completed.disconnect (on_get_access_tokens_complete);
904        txn.network_error.disconnect (on_get_access_tokens_error);
905
906        debug ("EVENT: network transaction to exchange authorization code for access tokens " +
907               "failed; response = '%s'", txn.get_response ());
908
909        if (!is_running ())
910            return;
911
912        host.post_error (err);
913    }
914
915    private void on_refresh_access_token_transaction_completed (Publishing.RESTSupport.Transaction
916            txn) {
917        txn.completed.disconnect (on_refresh_access_token_transaction_completed);
918        txn.network_error.disconnect (on_refresh_access_token_transaction_error);
919
920        debug ("EVENT: refresh access token transaction completed successfully.");
921
922        if (!is_running ())
923            return;
924
925        if (session.is_authenticated ()) // ignore these events if the session is already auth'd
926            return;
927
928        do_extract_tokens (txn.get_response ());
929    }
930
931    private void on_refresh_access_token_transaction_error (Publishing.RESTSupport.Transaction txn,
932            Spit.Publishing.PublishingError err) {
933        txn.completed.disconnect (on_refresh_access_token_transaction_completed);
934        txn.network_error.disconnect (on_refresh_access_token_transaction_error);
935
936        debug ("EVENT: refresh access token transaction caused a network error.");
937
938        if (!is_running ())
939            return;
940
941        if (session.is_authenticated ()) // ignore these events if the session is already auth'd
942            return;
943
944        // 400 errors indicate that the OAuth client ID and secret have become invalid. In most
945        // cases, this can be fixed by logging the user out
946        if (txn.get_status_code () == 400) {
947            do_logout ();
948            return;
949        }
950
951        host.post_error (err);
952    }
953
954    private void on_refresh_token_available (string token) {
955        debug ("EVENT: an OAuth refresh token has become available; token = '%s'.", token);
956
957        if (!is_running ())
958            return;
959
960        session.refresh_token = token;
961    }
962
963    private void on_access_token_available (string token) {
964        debug ("EVENT: an OAuth access token has become available; token = '%s'.", token);
965
966        if (!is_running ())
967            return;
968
969        session.access_token = token;
970
971        do_fetch_username ();
972    }
973
974    private void on_fetch_username_transaction_completed (Publishing.RESTSupport.Transaction txn) {
975        txn.completed.disconnect (on_fetch_username_transaction_completed);
976        txn.network_error.disconnect (on_fetch_username_transaction_error);
977
978        debug ("EVENT: username fetch transaction completed successfully.");
979
980        if (!is_running ())
981            return;
982
983        do_extract_username (txn.get_response ());
984    }
985
986    private void on_fetch_username_transaction_error (Publishing.RESTSupport.Transaction txn,
987            Spit.Publishing.PublishingError err) {
988        txn.completed.disconnect (on_fetch_username_transaction_completed);
989        txn.network_error.disconnect (on_fetch_username_transaction_error);
990
991        debug ("EVENT: username fetch transaction caused a network error");
992
993        if (!is_running ())
994            return;
995
996        host.post_error (err);
997    }
998
999    private void do_get_access_tokens (string auth_code) {
1000        debug ("ACTION: exchanging authorization code for access & refresh tokens");
1001
1002        host.install_login_wait_pane ();
1003
1004        GetAccessTokensTransaction tokens_txn = new GetAccessTokensTransaction (session, auth_code);
1005        tokens_txn.completed.connect (on_get_access_tokens_complete);
1006        tokens_txn.network_error.connect (on_get_access_tokens_error);
1007
1008        try {
1009            tokens_txn.execute ();
1010        } catch (Spit.Publishing.PublishingError err) {
1011            host.post_error (err);
1012        }
1013    }
1014
1015    private void do_hosted_web_authentication () {
1016        debug ("ACTION: running OAuth authentication flow in hosted web pane.");
1017
1018        string user_authorization_url = "https://accounts.google.com/o/oauth2/auth?" +
1019                                        "response_type=code&" +
1020                                        "client_id=" + OAUTH_CLIENT_ID + "&" +
1021                                        "redirect_uri=" + Soup.URI.encode ("urn:ietf:wg:oauth:2.0:oob", null) + "&" +
1022                                        "scope=" + Soup.URI.encode (scope, null) + "+" +
1023                                        Soup.URI.encode ("https://www.googleapis.com/auth/userinfo.profile", null) + "&" +
1024                                        "state=connect&" +
1025                                        "access_type=offline&" +
1026                                        "approval_prompt=force";
1027
1028        web_auth_pane = new WebAuthenticationPane (user_authorization_url);
1029        web_auth_pane.authorized.connect (on_web_auth_pane_authorized);
1030
1031        host.install_dialog_pane (web_auth_pane);
1032
1033    }
1034
1035    private void do_exchange_refresh_token_for_access_token () {
1036        debug ("ACTION: exchanging OAuth refresh token for OAuth access token.");
1037
1038        host.install_login_wait_pane ();
1039
1040        RefreshAccessTokenTransaction txn = new RefreshAccessTokenTransaction (session);
1041
1042        txn.completed.connect (on_refresh_access_token_transaction_completed);
1043        txn.network_error.connect (on_refresh_access_token_transaction_error);
1044
1045        try {
1046            txn.execute ();
1047        } catch (Spit.Publishing.PublishingError err) {
1048            host.post_error (err);
1049        }
1050    }
1051
1052    private void do_extract_tokens (string response_body) {
1053        debug ("ACTION: extracting OAuth tokens from body of server response");
1054
1055        Json.Parser parser = new Json.Parser ();
1056
1057        try {
1058            parser.load_from_data (response_body);
1059        } catch (Error err) {
1060            host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE (
1061                                 "Couldn't parse JSON response: " + err.message));
1062            return;
1063        }
1064
1065        Json.Object response_obj = parser.get_root ().get_object ();
1066
1067        if ((!response_obj.has_member ("access_token")) && (!response_obj.has_member ("refresh_token"))) {
1068            host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE (
1069                                 "neither access_token nor refresh_token not present in server response"));
1070            return;
1071        }
1072
1073        if (response_obj.has_member ("refresh_token")) {
1074            string refresh_token = response_obj.get_string_member ("refresh_token");
1075
1076            if (refresh_token != "")
1077                on_refresh_token_available (refresh_token);
1078        }
1079
1080        if (response_obj.has_member ("access_token")) {
1081            string access_token = response_obj.get_string_member ("access_token");
1082
1083            if (access_token != "")
1084                on_access_token_available (access_token);
1085        }
1086    }
1087
1088    private void do_fetch_username () {
1089        debug ("ACTION: running network transaction to fetch username.");
1090
1091        host.install_login_wait_pane ();
1092        host.set_service_locked (true);
1093
1094        UsernameFetchTransaction txn = new UsernameFetchTransaction (session);
1095        txn.completed.connect (on_fetch_username_transaction_completed);
1096        txn.network_error.connect (on_fetch_username_transaction_error);
1097
1098        try {
1099            txn.execute ();
1100        } catch (Error err) {
1101            host.post_error (err);
1102        }
1103    }
1104
1105    private void do_extract_username (string response_body) {
1106        debug ("ACTION: extracting username from body of server response");
1107
1108        Json.Parser parser = new Json.Parser ();
1109
1110        try {
1111            parser.load_from_data (response_body);
1112        } catch (Error err) {
1113            host.post_error (new Spit.Publishing.PublishingError.MALFORMED_RESPONSE (
1114                                 "Couldn't parse JSON response: " + err.message));
1115            return;
1116        }
1117
1118        Json.Object response_obj = parser.get_root ().get_object ();
1119
1120        if (response_obj.has_member ("name")) {
1121            string username = response_obj.get_string_member ("name");
1122
1123            if (username != "")
1124                session.user_name = username;
1125        }
1126
1127        if (response_obj.has_member ("access_token")) {
1128            string access_token = response_obj.get_string_member ("access_token");
1129
1130            if (access_token != "")
1131                session.access_token = access_token;
1132        }
1133
1134        // by the time we get a username, the session should be authenticated, or else something
1135        // really tragic has happened
1136        assert (session.is_authenticated ());
1137
1138        on_login_flow_complete ();
1139    }
1140
1141    protected unowned Spit.Publishing.PluginHost get_host () {
1142        return host;
1143    }
1144
1145    protected GoogleSession get_session () {
1146        return session;
1147    }
1148
1149    protected void start_oauth_flow (string? refresh_token = null) {
1150        if (refresh_token != null && refresh_token != "") {
1151            session.refresh_token = refresh_token;
1152            do_exchange_refresh_token_for_access_token ();
1153        } else {
1154            if (WebAuthenticationPane.is_cache_dirty ()) {
1155                host.install_static_message_pane (_ ("You have already logged in and out of a Google service during this session.\n\nTo continue publishing to Google services, quit and restart this software, then try publishing again."));
1156                return;
1157            }
1158
1159            do_hosted_web_authentication ();
1160        }
1161    }
1162
1163    protected abstract void on_login_flow_complete ();
1164
1165    protected abstract void do_logout ();
1166
1167    public abstract bool is_running ();
1168
1169    public abstract void start ();
1170
1171    public abstract void stop ();
1172
1173    public Spit.Publishing.Service get_service () {
1174        return service;
1175    }
1176}
1177}
1178