]> git.basschouten.com Git - openhab-addons.git/blob
0b38a5ec3f12676e52f1cb49df905cb2b212a319
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.amazonechocontrol.internal;
14
15 import static org.openhab.binding.amazonechocontrol.internal.AmazonEchoControlBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.URISyntaxException;
19 import java.net.URLDecoder;
20 import java.net.URLEncoder;
21 import java.nio.charset.StandardCharsets;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.stream.Collectors;
26
27 import javax.net.ssl.HttpsURLConnection;
28 import javax.servlet.ServletException;
29 import javax.servlet.http.HttpServlet;
30 import javax.servlet.http.HttpServletRequest;
31 import javax.servlet.http.HttpServletResponse;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.amazonechocontrol.internal.handler.AccountHandler;
36 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
37 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates.BluetoothState;
38 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates.PairedDevice;
39 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
40 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
41 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
42 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
43 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists.PlayList;
44 import org.openhab.core.thing.Thing;
45 import org.osgi.service.http.HttpService;
46 import org.osgi.service.http.NamespaceException;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49 import org.unbescape.html.HtmlEscape;
50
51 import com.google.gson.Gson;
52 import com.google.gson.JsonSyntaxException;
53
54 /**
55  * Provides the following functions
56  * --- Login ---
57  * Simple http proxy to forward the login dialog from amazon to the user through the binding
58  * so the user can enter a captcha or other extended login information
59  * --- List of devices ---
60  * Used to get the device information of new devices which are currently not known
61  * --- List of IDs ---
62  * Simple possibility for a user to get the ids needed for writing rules
63  *
64  * @author Michael Geramb - Initial Contribution
65  */
66 @NonNullByDefault
67 public class AccountServlet extends HttpServlet {
68
69     private static final long serialVersionUID = -1453738923337413163L;
70     private static final String FORWARD_URI_PART = "/FORWARD/";
71     private static final String PROXY_URI_PART = "/PROXY/";
72
73     private final Logger logger = LoggerFactory.getLogger(AccountServlet.class);
74
75     private final HttpService httpService;
76     private final String servletUrlWithoutRoot;
77     private final String servletUrl;
78     private final AccountHandler account;
79     private final String id;
80     private @Nullable Connection connectionToInitialize;
81     private final Gson gson;
82
83     public AccountServlet(HttpService httpService, String id, AccountHandler account, Gson gson) {
84         this.httpService = httpService;
85         this.account = account;
86         this.id = id;
87         this.gson = gson;
88
89         try {
90             servletUrlWithoutRoot = "amazonechocontrol/" + URLEncoder.encode(id, StandardCharsets.UTF_8);
91             servletUrl = "/" + servletUrlWithoutRoot;
92
93             httpService.registerServlet(servletUrl, this, null, httpService.createDefaultHttpContext());
94         } catch (NamespaceException | ServletException e) {
95             throw new IllegalStateException(e.getMessage());
96         }
97     }
98
99     private Connection reCreateConnection() {
100         Connection oldConnection = connectionToInitialize;
101         if (oldConnection == null) {
102             oldConnection = account.findConnection();
103         }
104         return new Connection(oldConnection, this.gson);
105     }
106
107     public void dispose() {
108         httpService.unregister(servletUrl);
109     }
110
111     @Override
112     protected void doPut(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
113             throws ServletException, IOException {
114         doVerb("PUT", req, resp);
115     }
116
117     @Override
118     protected void doDelete(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
119             throws ServletException, IOException {
120         doVerb("DELETE", req, resp);
121     }
122
123     @Override
124     protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
125             throws ServletException, IOException {
126         doVerb("POST", req, resp);
127     }
128
129     void doVerb(String verb, @Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException {
130         if (req == null) {
131             return;
132         }
133         if (resp == null) {
134             return;
135         }
136         String requestUri = req.getRequestURI();
137         if (requestUri == null) {
138             return;
139         }
140         String baseUrl = requestUri.substring(servletUrl.length());
141         String uri = baseUrl;
142         String queryString = req.getQueryString();
143         if (queryString != null && queryString.length() > 0) {
144             uri += "?" + queryString;
145         }
146
147         Connection connection = this.account.findConnection();
148         if (connection != null && "/changedomain".equals(uri)) {
149             Map<String, String[]> map = req.getParameterMap();
150             String[] domainArray = map.get("domain");
151             if (domainArray == null) {
152                 logger.warn("Could not determine domain");
153                 return;
154             }
155             String domain = domainArray[0];
156             String loginData = connection.serializeLoginData();
157             Connection newConnection = new Connection(null, this.gson);
158             if (newConnection.tryRestoreLogin(loginData, domain)) {
159                 account.setConnection(newConnection);
160             }
161             resp.sendRedirect(servletUrl);
162             return;
163         }
164         if (uri.startsWith(PROXY_URI_PART)) {
165             // handle proxy request
166
167             if (connection == null) {
168                 returnError(resp, "Account not online");
169                 return;
170             }
171             String getUrl = "https://alexa." + connection.getAmazonSite() + "/"
172                     + uri.substring(PROXY_URI_PART.length());
173
174             String postData = null;
175             if ("POST".equals(verb) || "PUT".equals(verb)) {
176                 postData = req.getReader().lines().collect(Collectors.joining(System.lineSeparator()));
177             }
178
179             this.handleProxyRequest(connection, resp, verb, getUrl, null, postData, true, connection.getAmazonSite());
180             return;
181         }
182
183         // handle post of login page
184         connection = this.connectionToInitialize;
185         if (connection == null) {
186             returnError(resp, "Connection not in initialize mode.");
187             return;
188         }
189
190         resp.addHeader("content-type", "text/html;charset=UTF-8");
191
192         Map<String, String[]> map = req.getParameterMap();
193         StringBuilder postDataBuilder = new StringBuilder();
194         for (String name : map.keySet()) {
195             if (postDataBuilder.length() > 0) {
196                 postDataBuilder.append('&');
197             }
198
199             postDataBuilder.append(name);
200             postDataBuilder.append('=');
201             String value = "";
202             if ("failedSignInCount".equals(name)) {
203                 value = "ape:AA==";
204             } else {
205                 String[] strings = map.get(name);
206                 if (strings != null && strings.length > 0 && strings[0] != null) {
207                     value = strings[0];
208                 }
209             }
210             postDataBuilder.append(URLEncoder.encode(value, StandardCharsets.UTF_8.name()));
211         }
212
213         uri = req.getRequestURI();
214         if (uri == null || !uri.startsWith(servletUrl)) {
215             returnError(resp, "Invalid request uri '" + uri + "'");
216             return;
217         }
218         String relativeUrl = uri.substring(servletUrl.length()).replace(FORWARD_URI_PART, "/");
219
220         String site = connection.getAmazonSite();
221         if (relativeUrl.startsWith("/ap/signin")) {
222             site = "amazon.com";
223         }
224         String postUrl = "https://www." + site + relativeUrl;
225         queryString = req.getQueryString();
226         if (queryString != null && queryString.length() > 0) {
227             postUrl += "?" + queryString;
228         }
229         String referer = "https://www." + site;
230         String postData = postDataBuilder.toString();
231         handleProxyRequest(connection, resp, "POST", postUrl, referer, postData, false, site);
232     }
233
234     @Override
235     protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException {
236         if (req == null) {
237             return;
238         }
239         if (resp == null) {
240             return;
241         }
242         String requestUri = req.getRequestURI();
243         if (requestUri == null) {
244             return;
245         }
246         String baseUrl = requestUri.substring(servletUrl.length());
247         String uri = baseUrl;
248         String queryString = req.getQueryString();
249         if (queryString != null && queryString.length() > 0) {
250             uri += "?" + queryString;
251         }
252         logger.debug("doGet {}", uri);
253         try {
254             Connection connection = this.connectionToInitialize;
255             if (uri.startsWith(FORWARD_URI_PART) && connection != null) {
256                 String getUrl = "https://www." + connection.getAmazonSite() + "/"
257                         + uri.substring(FORWARD_URI_PART.length());
258
259                 this.handleProxyRequest(connection, resp, "GET", getUrl, null, null, false, connection.getAmazonSite());
260                 return;
261             }
262
263             connection = this.account.findConnection();
264             if (uri.startsWith(PROXY_URI_PART)) {
265                 // handle proxy request
266
267                 if (connection == null) {
268                     returnError(resp, "Account not online");
269                     return;
270                 }
271                 String getUrl = "https://alexa." + connection.getAmazonSite() + "/"
272                         + uri.substring(PROXY_URI_PART.length());
273
274                 this.handleProxyRequest(connection, resp, "GET", getUrl, null, null, false, connection.getAmazonSite());
275                 return;
276             }
277
278             if (connection != null && connection.verifyLogin()) {
279                 // handle commands
280                 if ("/logout".equals(baseUrl) || "/logout/".equals(baseUrl)) {
281                     this.connectionToInitialize = reCreateConnection();
282                     this.account.setConnection(null);
283                     resp.sendRedirect(this.servletUrl);
284                     return;
285                 }
286                 // handle commands
287                 if ("/newdevice".equals(baseUrl) || "/newdevice/".equals(baseUrl)) {
288                     this.connectionToInitialize = new Connection(null, this.gson);
289                     this.account.setConnection(null);
290                     resp.sendRedirect(this.servletUrl);
291                     return;
292                 }
293
294                 if ("/devices".equals(baseUrl) || "/devices/".equals(baseUrl)) {
295                     handleDevices(resp, connection);
296                     return;
297                 }
298                 if ("/changeDomain".equals(baseUrl) || "/changeDomain/".equals(baseUrl)) {
299                     handleChangeDomain(resp, connection);
300                     return;
301                 }
302                 if ("/ids".equals(baseUrl) || "/ids/".equals(baseUrl)) {
303                     String serialNumber = getQueryMap(queryString).get("serialNumber");
304                     Device device = account.findDeviceJson(serialNumber);
305                     if (device != null) {
306                         Thing thing = account.findThingBySerialNumber(device.serialNumber);
307                         handleIds(resp, connection, device, thing);
308                         return;
309                     }
310                 }
311                 // return hint that everything is ok
312                 handleDefaultPageResult(resp, "The Account is logged in.", connection);
313                 return;
314             }
315             connection = this.connectionToInitialize;
316             if (connection == null) {
317                 connection = this.reCreateConnection();
318                 this.connectionToInitialize = connection;
319             }
320
321             if (!"/".equals(uri)) {
322                 String newUri = req.getServletPath() + "/";
323                 resp.sendRedirect(newUri);
324                 return;
325             }
326
327             String html = connection.getLoginPage();
328             returnHtml(connection, resp, html, "amazon.com");
329         } catch (URISyntaxException | InterruptedException e) {
330             logger.warn("get failed with uri syntax error", e);
331         }
332     }
333
334     public Map<String, String> getQueryMap(@Nullable String query) {
335         Map<String, String> map = new HashMap<>();
336         if (query != null) {
337             String[] params = query.split("&");
338             for (String param : params) {
339                 String[] elements = param.split("=");
340                 if (elements.length == 2) {
341                     String name = elements[0];
342                     String value = URLDecoder.decode(elements[1], StandardCharsets.UTF_8);
343                     map.put(name, value);
344                 }
345             }
346         }
347         return map;
348     }
349
350     private void handleChangeDomain(HttpServletResponse resp, Connection connection) {
351         StringBuilder html = createPageStart("Change Domain");
352         html.append("<form action='");
353         html.append(servletUrl);
354         html.append("/changedomain' method='post'>\nDomain:\n<input type='text' name='domain' value='");
355         html.append(connection.getAmazonSite());
356         html.append("'>\n<br>\n<input type=\"submit\" value=\"Submit\">\n</form>");
357
358         createPageEndAndSent(resp, html);
359     }
360
361     private void handleDefaultPageResult(HttpServletResponse resp, String message, Connection connection)
362             throws IOException {
363         StringBuilder html = createPageStart("");
364         html.append(HtmlEscape.escapeHtml4(message));
365         // logout link
366         html.append(" <a href='" + servletUrl + "/logout' >");
367         html.append(HtmlEscape.escapeHtml4("Logout"));
368         html.append("</a>");
369         // newdevice link
370         html.append(" | <a href='" + servletUrl + "/newdevice' >");
371         html.append(HtmlEscape.escapeHtml4("Logout and create new device id"));
372         html.append("</a>");
373         // customer id
374         html.append("<br>Customer Id: ");
375         html.append(HtmlEscape.escapeHtml4(connection.getCustomerId()));
376         // customer name
377         html.append("<br>Customer Name: ");
378         html.append(HtmlEscape.escapeHtml4(connection.getCustomerName()));
379         // device name
380         html.append("<br>App name: ");
381         html.append(HtmlEscape.escapeHtml4(connection.getDeviceName()));
382         // connection
383         html.append("<br>Connected to: ");
384         html.append(HtmlEscape.escapeHtml4(connection.getAlexaServer()));
385         // domain
386         html.append(" <a href='");
387         html.append(servletUrl);
388         html.append("/changeDomain'>Change</a>");
389
390         // Main UI link
391         html.append("<br><a href='/#!/settings/things/" + BINDING_ID + ":"
392                 + URLEncoder.encode(THING_TYPE_ACCOUNT.getId(), "UTF8") + ":" + URLEncoder.encode(id, "UTF8") + "'>");
393         html.append(HtmlEscape.escapeHtml4("Check Thing in Main UI"));
394         html.append("</a><br><br>");
395
396         // device list
397         html.append(
398                 "<table><tr><th align='left'>Device</th><th align='left'>Serial Number</th><th align='left'>State</th><th align='left'>Thing</th><th align='left'>Family</th><th align='left'>Type</th><th align='left'>Customer Id</th></tr>");
399         for (Device device : this.account.getLastKnownDevices()) {
400
401             html.append("<tr><td>");
402             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.accountName)));
403             html.append("</td><td>");
404             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.serialNumber)));
405             html.append("</td><td>");
406             html.append(HtmlEscape.escapeHtml4(device.online ? "Online" : "Offline"));
407             html.append("</td><td>");
408             Thing accountHandler = account.findThingBySerialNumber(device.serialNumber);
409             if (accountHandler != null) {
410                 html.append("<a href='" + servletUrl + "/ids/?serialNumber="
411                         + URLEncoder.encode(device.serialNumber, "UTF8") + "'>"
412                         + HtmlEscape.escapeHtml4(accountHandler.getLabel()) + "</a>");
413             } else {
414                 html.append("<a href='" + servletUrl + "/ids/?serialNumber="
415                         + URLEncoder.encode(device.serialNumber, "UTF8") + "'>" + HtmlEscape.escapeHtml4("Not defined")
416                         + "</a>");
417             }
418             html.append("</td><td>");
419             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.deviceFamily)));
420             html.append("</td><td>");
421             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.deviceType)));
422             html.append("</td><td>");
423             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.deviceOwnerCustomerId)));
424             html.append("</td>");
425             html.append("</tr>");
426         }
427         html.append("</table>");
428         createPageEndAndSent(resp, html);
429     }
430
431     private void handleDevices(HttpServletResponse resp, Connection connection)
432             throws IOException, URISyntaxException, InterruptedException {
433         returnHtml(connection, resp, "<html>" + HtmlEscape.escapeHtml4(connection.getDeviceListJson()) + "</html>");
434     }
435
436     private String nullReplacement(@Nullable String text) {
437         if (text == null) {
438             return "<unknown>";
439         }
440         return text;
441     }
442
443     StringBuilder createPageStart(String title) {
444         StringBuilder html = new StringBuilder();
445         html.append("<html><head><title>"
446                 + HtmlEscape.escapeHtml4(BINDING_NAME + " - " + this.account.getThing().getLabel()));
447         if (!title.isEmpty()) {
448             html.append(" - ");
449             html.append(HtmlEscape.escapeHtml4(title));
450         }
451         html.append("</title><head><body>");
452         html.append("<h1>" + HtmlEscape.escapeHtml4(BINDING_NAME + " - " + this.account.getThing().getLabel()));
453         if (!title.isEmpty()) {
454             html.append(" - ");
455             html.append(HtmlEscape.escapeHtml4(title));
456         }
457         html.append("</h1>");
458         return html;
459     }
460
461     private void createPageEndAndSent(HttpServletResponse resp, StringBuilder html) {
462         // account overview link
463         html.append("<br><a href='" + servletUrl + "/../' >");
464         html.append(HtmlEscape.escapeHtml4("Account overview"));
465         html.append("</a><br>");
466
467         html.append("</body></html>");
468         resp.addHeader("content-type", "text/html;charset=UTF-8");
469         try {
470             resp.getWriter().write(html.toString());
471         } catch (IOException e) {
472             logger.warn("return html failed with IO error", e);
473         }
474     }
475
476     private void handleIds(HttpServletResponse resp, Connection connection, Device device, @Nullable Thing thing)
477             throws IOException, URISyntaxException {
478         StringBuilder html;
479         if (thing != null) {
480             html = createPageStart("Channel Options - " + thing.getLabel());
481         } else {
482             html = createPageStart("Device Information - No thing defined");
483         }
484         renderBluetoothMacChannel(connection, device, html);
485         renderAmazonMusicPlaylistIdChannel(connection, device, html);
486         renderPlayAlarmSoundChannel(connection, device, html);
487         renderMusicProviderIdChannel(connection, html);
488         renderCapabilities(connection, device, html);
489         createPageEndAndSent(resp, html);
490     }
491
492     private void renderCapabilities(Connection connection, Device device, StringBuilder html) {
493         html.append("<h2>Capabilities</h2>");
494         html.append("<table><tr><th align='left'>Name</th></tr>");
495         device.getCapabilities().forEach(
496                 capability -> html.append("<tr><td>").append(HtmlEscape.escapeHtml4(capability)).append("</td></tr>"));
497         html.append("</table>");
498     }
499
500     private void renderMusicProviderIdChannel(Connection connection, StringBuilder html) {
501         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_MUSIC_PROVIDER_ID)).append("</h2>");
502         html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
503         List<JsonMusicProvider> musicProviders = connection.getMusicProviders();
504         for (JsonMusicProvider musicProvider : musicProviders) {
505             List<String> properties = musicProvider.supportedProperties;
506             String providerId = musicProvider.id;
507             String displayName = musicProvider.displayName;
508             if (properties != null && properties.contains("Alexa.Music.PlaySearchPhrase") && providerId != null
509                     && !providerId.isEmpty() && "AVAILABLE".equals(musicProvider.availability) && displayName != null
510                     && !displayName.isEmpty()) {
511                 html.append("<tr><td>");
512                 html.append(HtmlEscape.escapeHtml4(displayName));
513                 html.append("</td><td>");
514                 html.append(HtmlEscape.escapeHtml4(providerId));
515                 html.append("</td></tr>");
516             }
517         }
518         html.append("</table>");
519     }
520
521     private void renderPlayAlarmSoundChannel(Connection connection, Device device, StringBuilder html) {
522         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_PLAY_ALARM_SOUND)).append("</h2>");
523         List<JsonNotificationSound> notificationSounds = List.of();
524         String errorMessage = "No notifications sounds found";
525         try {
526             notificationSounds = connection.getNotificationSounds(device);
527         } catch (IOException | HttpException | URISyntaxException | JsonSyntaxException | ConnectionException
528                 | InterruptedException e) {
529             errorMessage = e.getLocalizedMessage();
530         }
531         if (!notificationSounds.isEmpty()) {
532             html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
533             for (JsonNotificationSound notificationSound : notificationSounds) {
534                 if (notificationSound.folder == null && notificationSound.providerId != null
535                         && notificationSound.id != null && notificationSound.displayName != null) {
536                     String providerSoundId = notificationSound.providerId + ":" + notificationSound.id;
537
538                     html.append("<tr><td>");
539                     html.append(HtmlEscape.escapeHtml4(notificationSound.displayName));
540                     html.append("</td><td>");
541                     html.append(HtmlEscape.escapeHtml4(providerSoundId));
542                     html.append("</td></tr>");
543                 }
544             }
545             html.append("</table>");
546         } else {
547             html.append(HtmlEscape.escapeHtml4(errorMessage));
548         }
549     }
550
551     private void renderAmazonMusicPlaylistIdChannel(Connection connection, Device device, StringBuilder html) {
552         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_AMAZON_MUSIC_PLAY_LIST_ID))
553                 .append("</h2>");
554
555         JsonPlaylists playLists = null;
556         String errorMessage = "No playlists found";
557         try {
558             playLists = connection.getPlaylists(device);
559         } catch (IOException | HttpException | URISyntaxException | JsonSyntaxException | ConnectionException
560                 | InterruptedException e) {
561             errorMessage = e.getLocalizedMessage();
562         }
563
564         if (playLists != null) {
565             Map<String, PlayList @Nullable []> playlistMap = playLists.playlists;
566             if (playlistMap != null && !playlistMap.isEmpty()) {
567                 html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
568
569                 for (PlayList[] innerLists : playlistMap.values()) {
570                     {
571                         if (innerLists != null && innerLists.length > 0) {
572                             PlayList playList = innerLists[0];
573                             if (playList != null && playList.playlistId != null && playList.title != null) {
574                                 html.append("<tr><td>");
575                                 html.append(HtmlEscape.escapeHtml4(nullReplacement(playList.title)));
576                                 html.append("</td><td>");
577                                 html.append(HtmlEscape.escapeHtml4(nullReplacement(playList.playlistId)));
578                                 html.append("</td></tr>");
579                             }
580                         }
581                     }
582                 }
583                 html.append("</table>");
584             } else {
585                 html.append(HtmlEscape.escapeHtml4(errorMessage));
586             }
587         }
588     }
589
590     private void renderBluetoothMacChannel(Connection connection, Device device, StringBuilder html) {
591         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_BLUETOOTH_MAC)).append("</h2>");
592         JsonBluetoothStates bluetoothStates = connection.getBluetoothConnectionStates();
593         if (bluetoothStates == null) {
594             return;
595         }
596         BluetoothState[] innerStates = bluetoothStates.bluetoothStates;
597         if (innerStates == null) {
598             return;
599         }
600         for (BluetoothState state : innerStates) {
601             if (state == null) {
602                 continue;
603             }
604             String stateDeviceSerialNumber = state.deviceSerialNumber;
605             if ((stateDeviceSerialNumber == null && device.serialNumber == null)
606                     || (stateDeviceSerialNumber != null && stateDeviceSerialNumber.equals(device.serialNumber))) {
607                 List<PairedDevice> pairedDeviceList = state.getPairedDeviceList();
608                 if (!pairedDeviceList.isEmpty()) {
609                     html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
610                     for (PairedDevice pairedDevice : pairedDeviceList) {
611                         html.append("<tr><td>");
612                         html.append(HtmlEscape.escapeHtml4(nullReplacement(pairedDevice.friendlyName)));
613                         html.append("</td><td>");
614                         html.append(HtmlEscape.escapeHtml4(nullReplacement(pairedDevice.address)));
615                         html.append("</td></tr>");
616                     }
617                     html.append("</table>");
618                 } else {
619                     html.append(HtmlEscape.escapeHtml4("No bluetooth devices paired"));
620                 }
621             }
622         }
623     }
624
625     void handleProxyRequest(Connection connection, HttpServletResponse resp, String verb, String url,
626             @Nullable String referer, @Nullable String postData, boolean json, String site) throws IOException {
627         HttpsURLConnection urlConnection;
628         try {
629             Map<String, String> headers = null;
630             if (referer != null) {
631                 headers = new HashMap<>();
632                 headers.put("Referer", referer);
633             }
634
635             urlConnection = connection.makeRequest(verb, url, postData, json, false, headers, 0);
636             if (urlConnection.getResponseCode() == 302) {
637                 {
638                     String location = urlConnection.getHeaderField("location");
639                     if (location.contains("/ap/maplanding")) {
640                         try {
641                             connection.registerConnectionAsApp(location);
642                             account.setConnection(connection);
643                             handleDefaultPageResult(resp, "Login succeeded", connection);
644                             this.connectionToInitialize = null;
645                             return;
646                         } catch (URISyntaxException | ConnectionException e) {
647                             returnError(resp,
648                                     "Login to '" + connection.getAmazonSite() + "' failed: " + e.getLocalizedMessage());
649                             this.connectionToInitialize = null;
650                             return;
651                         }
652                     }
653
654                     String startString = "https://www." + connection.getAmazonSite() + "/";
655                     String newLocation = null;
656                     if (location.startsWith(startString) && connection.getIsLoggedIn()) {
657                         newLocation = servletUrl + PROXY_URI_PART + location.substring(startString.length());
658                     } else if (location.startsWith(startString)) {
659                         newLocation = servletUrl + FORWARD_URI_PART + location.substring(startString.length());
660                     } else {
661                         startString = "/";
662                         if (location.startsWith(startString)) {
663                             newLocation = servletUrl + FORWARD_URI_PART + location.substring(startString.length());
664                         }
665                     }
666                     if (newLocation != null) {
667                         logger.debug("Redirect mapped from {} to {}", location, newLocation);
668
669                         resp.sendRedirect(newLocation);
670                         return;
671                     }
672                     returnError(resp, "Invalid redirect to '" + location + "'");
673                     return;
674                 }
675             }
676         } catch (URISyntaxException | ConnectionException | InterruptedException e) {
677             returnError(resp, e.getLocalizedMessage());
678             return;
679         }
680         String response = connection.convertStream(urlConnection);
681         returnHtml(connection, resp, response, site);
682     }
683
684     private void returnHtml(Connection connection, HttpServletResponse resp, String html) {
685         returnHtml(connection, resp, html, connection.getAmazonSite());
686     }
687
688     private void returnHtml(Connection connection, HttpServletResponse resp, String html, String amazonSite) {
689         String resultHtml = html.replace("action=\"/", "action=\"" + servletUrl + "/")
690                 .replace("action=\"&#x2F;", "action=\"" + servletUrl + "/")
691                 .replace("https://www." + amazonSite + "/", servletUrl + "/")
692                 .replace("https://www." + amazonSite + ":443" + "/", servletUrl + "/")
693                 .replace("https:&#x2F;&#x2F;www." + amazonSite + "&#x2F;", servletUrl + "/")
694                 .replace("https:&#x2F;&#x2F;www." + amazonSite + ":443" + "&#x2F;", servletUrl + "/")
695                 .replace("http://www." + amazonSite + "/", servletUrl + "/")
696                 .replace("http:&#x2F;&#x2F;www." + amazonSite + "&#x2F;", servletUrl + "/");
697
698         resp.addHeader("content-type", "text/html;charset=UTF-8");
699         try {
700             resp.getWriter().write(resultHtml);
701         } catch (IOException e) {
702             logger.warn("return html failed with IO error", e);
703         }
704     }
705
706     void returnError(HttpServletResponse resp, @Nullable String errorMessage) {
707         try {
708             String message = errorMessage != null ? errorMessage : "null";
709             resp.getWriter().write("<html>" + HtmlEscape.escapeHtml4(message) + "<br><a href='" + servletUrl
710                     + "'>Try again</a></html>");
711         } catch (IOException e) {
712             logger.info("Returning error message failed", e);
713         }
714     }
715 }