1/* Copyright 2016 Software Freedom Conservancy Inc.
2 *
3 * This software is licensed under the GNU Lesser General Public License
4 * (version 2.1 or later).  See the COPYING file in this distribution.
5 */
6
7using Shotwell;
8using Shotwell.Plugins;
9
10namespace Publishing.Authenticator.Shotwell.Facebook {
11    private const string APPLICATION_ID = "1612018629063184";
12
13    private class WebAuthenticationPane : Common.WebAuthenticationPane {
14        private static bool cache_dirty = false;
15
16        public signal void login_succeeded(string success_url);
17        public signal void login_failed();
18
19        public WebAuthenticationPane() {
20            Object (login_uri : get_login_url ());
21        }
22
23        private class LocaleLookup {
24            public string prefix;
25            public string translation;
26            public string? exception_code;
27            public string? exception_translation;
28            public string? exception_code_2;
29            public string? exception_translation_2;
30
31            public LocaleLookup(string prefix, string translation, string? exception_code = null,
32                string? exception_translation  = null, string? exception_code_2  = null,
33                string? exception_translation_2 = null) {
34                this.prefix = prefix;
35                this.translation = translation;
36                this.exception_code = exception_code;
37                this.exception_translation = exception_translation;
38                this.exception_code_2 = exception_code_2;
39                this.exception_translation_2 = exception_translation_2;
40            }
41
42        }
43
44        private static LocaleLookup[] locale_lookup_table = {
45            new LocaleLookup( "es", "es-la", "ES", "es-es" ),
46            new LocaleLookup( "en", "en-gb", "US", "en-us" ),
47            new LocaleLookup( "fr", "fr-fr", "CA", "fr-ca" ),
48            new LocaleLookup( "pt", "pt-br", "PT", "pt-pt" ),
49            new LocaleLookup( "zh", "zh-cn", "HK", "zh-hk", "TW", "zh-tw" ),
50            new LocaleLookup( "af", "af-za" ),
51            new LocaleLookup( "ar", "ar-ar" ),
52            new LocaleLookup( "nb", "nb-no" ),
53            new LocaleLookup( "no", "nb-no" ),
54            new LocaleLookup( "id", "id-id" ),
55            new LocaleLookup( "ms", "ms-my" ),
56            new LocaleLookup( "ca", "ca-es" ),
57            new LocaleLookup( "cs", "cs-cz" ),
58            new LocaleLookup( "cy", "cy-gb" ),
59            new LocaleLookup( "da", "da-dk" ),
60            new LocaleLookup( "de", "de-de" ),
61            new LocaleLookup( "tl", "tl-ph" ),
62            new LocaleLookup( "ko", "ko-kr" ),
63            new LocaleLookup( "hr", "hr-hr" ),
64            new LocaleLookup( "it", "it-it" ),
65            new LocaleLookup( "lt", "lt-lt" ),
66            new LocaleLookup( "hu", "hu-hu" ),
67            new LocaleLookup( "nl", "nl-nl" ),
68            new LocaleLookup( "ja", "ja-jp" ),
69            new LocaleLookup( "nb", "nb-no" ),
70            new LocaleLookup( "no", "nb-no" ),
71            new LocaleLookup( "pl", "pl-pl" ),
72            new LocaleLookup( "ro", "ro-ro" ),
73            new LocaleLookup( "ru", "ru-ru" ),
74            new LocaleLookup( "sk", "sk-sk" ),
75            new LocaleLookup( "sl", "sl-si" ),
76            new LocaleLookup( "sv", "sv-se" ),
77            new LocaleLookup( "th", "th-th" ),
78            new LocaleLookup( "vi", "vi-vn" ),
79            new LocaleLookup( "tr", "tr-tr" ),
80            new LocaleLookup( "el", "el-gr" ),
81            new LocaleLookup( "bg", "bg-bg" ),
82            new LocaleLookup( "sr", "sr-rs" ),
83            new LocaleLookup( "he", "he-il" ),
84            new LocaleLookup( "hi", "hi-in" ),
85            new LocaleLookup( "bn", "bn-in" ),
86            new LocaleLookup( "pa", "pa-in" ),
87            new LocaleLookup( "ta", "ta-in" ),
88            new LocaleLookup( "te", "te-in" ),
89            new LocaleLookup( "ml", "ml-in" )
90        };
91
92        private static string get_system_locale_as_facebook_locale() {
93            unowned string? raw_system_locale = Intl.setlocale(LocaleCategory.ALL, "");
94            if (raw_system_locale == null || raw_system_locale == "")
95                return "www";
96
97            string system_locale = raw_system_locale.split(".")[0];
98
99            foreach (LocaleLookup locale_lookup in locale_lookup_table) {
100                if (!system_locale.has_prefix(locale_lookup.prefix))
101                    continue;
102
103                if (locale_lookup.exception_code != null) {
104                    assert(locale_lookup.exception_translation != null);
105
106                    if (system_locale.contains(locale_lookup.exception_code))
107                        return locale_lookup.exception_translation;
108                }
109
110                if (locale_lookup.exception_code_2 != null) {
111                    assert(locale_lookup.exception_translation_2 != null);
112
113                    if (system_locale.contains(locale_lookup.exception_code_2))
114                        return locale_lookup.exception_translation_2;
115                }
116
117                return locale_lookup.translation;
118            }
119
120            // default
121            return "www";
122        }
123
124        private static string get_login_url() {
125            var facebook_locale = get_system_locale_as_facebook_locale();
126
127            return "https://%s.facebook.com/dialog/oauth?client_id=%s&redirect_uri=https://www.facebook.com/connect/login_success.html&display=popup&scope=publish_actions,user_photos,user_videos&response_type=token".printf(facebook_locale, APPLICATION_ID);
128        }
129
130        public override void on_page_load() {
131            string loaded_url = get_view ().uri.dup();
132            debug("loaded url: " + loaded_url);
133
134            // strip parameters from the loaded url
135            if (loaded_url.contains("?")) {
136                int index = loaded_url.index_of_char('?');
137                string params = loaded_url[index:loaded_url.length];
138                loaded_url = loaded_url.replace(params, "");
139            }
140
141            // were we redirected to the facebook login success page?
142            if (loaded_url.contains("login_success")) {
143                cache_dirty = true;
144                login_succeeded(get_view ().uri);
145                return;
146            }
147
148            // were we redirected to the login total failure page?
149            if (loaded_url.contains("login_failure")) {
150                login_failed();
151                return;
152            }
153        }
154
155        public static bool is_cache_dirty() {
156            return cache_dirty;
157        }
158    }
159
160    internal class Facebook : Spit.Publishing.Authenticator, GLib.Object {
161        private Spit.Publishing.PluginHost host;
162        private Publishing.Authenticator.Shotwell.Facebook.WebAuthenticationPane web_auth_pane = null;
163        private GLib.HashTable<string, Variant> params;
164
165        private const string SERVICE_WELCOME_MESSAGE =
166    _("You are not currently logged into Facebook.\n\nIf you don’t yet have a Facebook account, you can create one during the login process. During login, Shotwell Connect may ask you for permission to upload photos and publish to your feed. These permissions are required for Shotwell Connect to function.");
167        private const string RESTART_ERROR_MESSAGE =
168    _("You have already logged in and out of Facebook during this Shotwell session.\nTo continue publishing to Facebook, quit and restart Shotwell, then try publishing again.");
169
170        /* Interface functions */
171        public Facebook(Spit.Publishing.PluginHost host) {
172            this.host = host;
173            this.params = new GLib.HashTable<string, Variant>(str_hash, str_equal);
174        }
175
176        public void authenticate() {
177            // Do we have saved user credentials? If so, go ahead and authenticate the session
178            // with the saved credentials and proceed with the publishing interaction. Otherwise, show
179            // the Welcome pane
180            if (is_persistent_session_valid()) {
181                var access_token = get_persistent_access_token();
182                this.params.insert("AccessToken", new Variant.string(access_token));
183                this.authenticated();
184                return;
185            }
186
187            // FIXME: Find a way for a proper logout
188            if (WebAuthenticationPane.is_cache_dirty()) {
189                host.set_service_locked(false);
190                host.install_static_message_pane(RESTART_ERROR_MESSAGE,
191                                                 Spit.Publishing.PluginHost.ButtonMode.CANCEL);
192            } else {
193                this.do_show_service_welcome_pane();
194            }
195        }
196
197        public bool can_logout() {
198            return true;
199        }
200
201        public GLib.HashTable<string, Variant> get_authentication_parameter() {
202            return this.params;
203        }
204
205        public void invalidate_persistent_session() {
206            debug("invalidating saved Facebook session.");
207            set_persistent_access_token("");
208        }
209
210        public void logout() {
211            invalidate_persistent_session();
212        }
213
214        public void refresh() {
215            // No-Op with Flickr
216        }
217
218        /* Private functions */
219        private bool is_persistent_session_valid() {
220            string? token = get_persistent_access_token();
221
222            if (token != null)
223                debug("existing Facebook session found in configuration database (access_token = %s).",
224                        token);
225            else
226                debug("no existing Facebook session available.");
227
228            return token != null;
229        }
230
231        private string? get_persistent_access_token() {
232            return host.get_config_string("access_token", null);
233        }
234
235        private void set_persistent_access_token(string access_token) {
236            host.set_config_string("access_token", access_token);
237        }
238
239        private void do_show_service_welcome_pane() {
240            debug("ACTION: showing service welcome pane.");
241
242            host.install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_login_clicked);
243            host.set_service_locked(false);
244        }
245
246        private void on_login_clicked() {
247            debug("EVENT: user clicked 'Login' on welcome pane.");
248
249            do_hosted_web_authentication();
250        }
251
252        private void do_hosted_web_authentication() {
253            debug("ACTION: doing hosted web authentication.");
254
255            this.host.set_service_locked(false);
256
257            this.web_auth_pane = new WebAuthenticationPane();
258            this.web_auth_pane.login_succeeded.connect(on_web_auth_pane_login_succeeded);
259            this.web_auth_pane.login_failed.connect(on_web_auth_pane_login_failed);
260
261            this.host.install_dialog_pane(this.web_auth_pane,
262                                          Spit.Publishing.PluginHost.ButtonMode.CANCEL);
263
264        }
265
266        private void on_web_auth_pane_login_succeeded(string success_url) {
267            debug("EVENT: hosted web login succeeded.");
268
269            do_authenticate_session(success_url);
270        }
271
272        private void on_web_auth_pane_login_failed() {
273            debug("EVENT: hosted web login failed.");
274
275            // In this case, "failed" doesn't mean that the user didn't enter the right username and
276            // password -- Facebook handles that case inside the Facebook Connect web control. Instead,
277            // it means that no session was initiated in response to our login request. The only
278            // way this happens is if the user clicks the "Cancel" button that appears inside
279            // the web control. In this case, the correct behavior is to return the user to the
280            // service welcome pane so that they can start the web interaction again.
281            do_show_service_welcome_pane();
282        }
283
284        private void do_authenticate_session(string good_login_uri) {
285            debug("ACTION: preparing to extract session information encoded in uri = '%s'",
286                 good_login_uri);
287
288            // the raw uri is percent-encoded, so decode it
289            string decoded_uri = Soup.URI.decode(good_login_uri);
290
291            // locate the access token within the URI
292            string? access_token = null;
293            int index = decoded_uri.index_of("#access_token=");
294            if (index >= 0)
295                access_token = decoded_uri[index:decoded_uri.length];
296            if (access_token == null) {
297                host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
298                    "Server redirect URL contained no access token"));
299                return;
300            }
301
302            // remove any trailing parameters from the session description string
303            string? trailing_params = null;
304            index = access_token.index_of_char('&');
305            if (index >= 0)
306                trailing_params = access_token[index:access_token.length];
307            if (trailing_params != null)
308                access_token = access_token.replace(trailing_params, "");
309
310            // remove the key from the session description string
311            access_token = access_token.replace("#access_token=", "");
312            this.params.insert("AccessToken", new Variant.string(access_token));
313            set_persistent_access_token(access_token);
314
315            this.authenticated();
316        }
317    }
318} // namespace Publishing.Facebook;
319