]> git.basschouten.com Git - openhab-addons.git/blob
8c50edc4f4ee5fed48e4d77b81c8d11cd9e90996
[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.spotify.internal;
14
15 import java.io.IOException;
16 import java.nio.charset.StandardCharsets;
17 import java.util.HashMap;
18 import java.util.List;
19 import java.util.Map;
20 import java.util.regex.Matcher;
21 import java.util.regex.Pattern;
22 import java.util.stream.Collectors;
23
24 import javax.servlet.ServletException;
25 import javax.servlet.http.HttpServlet;
26 import javax.servlet.http.HttpServletRequest;
27 import javax.servlet.http.HttpServletResponse;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.util.MultiMap;
32 import org.eclipse.jetty.util.StringUtil;
33 import org.eclipse.jetty.util.UrlEncoded;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
36
37 /**
38  * The {@link SpotifyAuthServlet} manages the authorization with the Spotify Web API. The servlet implements the
39  * Authorization Code flow and saves the resulting refreshToken with the bridge.
40  *
41  * @author Andreas Stenlund - Initial contribution
42  * @author Matthew Bowman - Initial contribution
43  * @author Hilbrand Bouwkamp - Rewrite, moved service part to service class. Uses templates, simplified calls.
44  */
45 @NonNullByDefault
46 public class SpotifyAuthServlet extends HttpServlet {
47
48     private static final long serialVersionUID = -4719613645562518231L;
49
50     private static final String CONTENT_TYPE = "text/html;charset=UTF-8";
51
52     // Simple HTML templates for inserting messages.
53     private static final String HTML_EMPTY_PLAYERS = "<p class='block'>Manually add a Spotify Player Bridge to authorize it here.<p>";
54     private static final String HTML_USER_AUTHORIZED = "<p class='block authorized'>Bridge authorized for user %s.</p>";
55     private static final String HTML_ERROR = "<p class='block error'>Call to Spotify failed with error: %s</p>";
56
57     private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}");
58
59     // Keys present in the index.html
60     private static final String KEY_PAGE_REFRESH = "pageRefresh";
61     private static final String HTML_META_REFRESH_CONTENT = "<meta http-equiv='refresh' content='10; url=%s'>";
62     private static final String KEY_AUTHORIZED_USER = "authorizedUser";
63     private static final String KEY_ERROR = "error";
64     private static final String KEY_PLAYERS = "players";
65     private static final String KEY_REDIRECT_URI = "redirectUri";
66     // Keys present in the player.html
67     private static final String PLAYER_ID = "player.id";
68     private static final String PLAYER_NAME = "player.name";
69     private static final String PLAYER_SPOTIFY_USER_ID = "player.user";
70     private static final String PLAYER_AUTHORIZED_CLASS = "player.authorized";
71     private static final String PLAYER_AUTHORIZE = "player.authorize";
72
73     private final Logger logger = LoggerFactory.getLogger(SpotifyAuthServlet.class);
74     private final SpotifyAuthService spotifyAuthService;
75     private final String indexTemplate;
76     private final String playerTemplate;
77
78     public SpotifyAuthServlet(SpotifyAuthService spotifyAuthService, String indexTemplate, String playerTemplate) {
79         this.spotifyAuthService = spotifyAuthService;
80         this.indexTemplate = indexTemplate;
81         this.playerTemplate = playerTemplate;
82     }
83
84     @Override
85     protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
86             throws ServletException, IOException {
87         logger.debug("Spotify auth callback servlet received GET request {}.", req.getRequestURI());
88         final String servletBaseURL = req.getRequestURL().toString();
89         final Map<String, String> replaceMap = new HashMap<>();
90
91         handleSpotifyRedirect(replaceMap, servletBaseURL, req.getQueryString());
92         resp.setContentType(CONTENT_TYPE);
93         replaceMap.put(KEY_REDIRECT_URI, servletBaseURL);
94         replaceMap.put(KEY_PLAYERS, formatPlayers(playerTemplate, servletBaseURL));
95         resp.getWriter().append(replaceKeysFromMap(indexTemplate, replaceMap));
96         resp.getWriter().close();
97     }
98
99     /**
100      * Handles a possible call from Spotify to the redirect_uri. If that is the case Spotify will pass the authorization
101      * codes via the url and these are processed. In case of an error this is shown to the user. If the user was
102      * authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to
103      * inform the user.
104      *
105      * @param replaceMap a map with key String values that will be mapped in the HTML templates.
106      * @param servletBaseURL the servlet base, which should be used as the Spotify redirect_uri value
107      * @param queryString the query part of the GET request this servlet is processing
108      */
109     private void handleSpotifyRedirect(Map<String, String> replaceMap, String servletBaseURL,
110             @Nullable String queryString) {
111         replaceMap.put(KEY_AUTHORIZED_USER, "");
112         replaceMap.put(KEY_ERROR, "");
113         replaceMap.put(KEY_PAGE_REFRESH, "");
114
115         if (queryString != null) {
116             final MultiMap<String> params = new MultiMap<>();
117             UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name());
118             final String reqCode = params.getString("code");
119             final String reqState = params.getString("state");
120             final String reqError = params.getString("error");
121
122             replaceMap.put(KEY_PAGE_REFRESH,
123                     params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL));
124             if (!StringUtil.isBlank(reqError)) {
125                 logger.debug("Spotify redirected with an error: {}", reqError);
126                 replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError));
127             } else if (!StringUtil.isBlank(reqState)) {
128                 try {
129                     replaceMap.put(KEY_AUTHORIZED_USER, String.format(HTML_USER_AUTHORIZED,
130                             spotifyAuthService.authorize(servletBaseURL, reqState, reqCode)));
131                 } catch (RuntimeException e) {
132                     logger.debug("Exception during authorizaton: ", e);
133                     replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage()));
134                 }
135             }
136         }
137     }
138
139     /**
140      * Formats the HTML of all available Spotify Bridge Players and returns it as a String
141      *
142      * @param playerTemplate The player template to format the player values in
143      * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button.
144      * @return A String with the players formatted with the player template
145      */
146     private String formatPlayers(String playerTemplate, String servletBaseURL) {
147         final List<SpotifyAccountHandler> players = spotifyAuthService.getSpotifyAccountHandlers();
148
149         return players.isEmpty() ? HTML_EMPTY_PLAYERS
150                 : players.stream().map(p -> formatPlayer(playerTemplate, p, servletBaseURL))
151                         .collect(Collectors.joining());
152     }
153
154     /**
155      * Formats the HTML of a Spotify Bridge Player and returns it as a String
156      *
157      * @param playerTemplate The player template to format the player values in
158      * @param handler The handler for the player to format
159      * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button.
160      * @return A String with the player formatted with the player template
161      */
162     private String formatPlayer(String playerTemplate, SpotifyAccountHandler handler, String servletBaseURL) {
163         final Map<String, String> map = new HashMap<>();
164
165         map.put(PLAYER_ID, handler.getUID().getAsString());
166         map.put(PLAYER_NAME, handler.getLabel());
167         final String spotifyUser = handler.getUser();
168
169         if (handler.isAuthorized()) {
170             map.put(PLAYER_AUTHORIZED_CLASS, " authorized");
171             map.put(PLAYER_SPOTIFY_USER_ID, String.format(" (Authorized user: %s)", spotifyUser));
172         } else if (!StringUtil.isBlank(spotifyUser)) {
173             map.put(PLAYER_AUTHORIZED_CLASS, " Unauthorized");
174             map.put(PLAYER_SPOTIFY_USER_ID, String.format(" (Unauthorized user: %s)", spotifyUser));
175         } else {
176             map.put(PLAYER_AUTHORIZED_CLASS, "");
177             map.put(PLAYER_SPOTIFY_USER_ID, "");
178         }
179
180         map.put(PLAYER_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL));
181         return replaceKeysFromMap(playerTemplate, map);
182     }
183
184     /**
185      * Replaces all keys from the map found in the template with values from the map. If the key is not found the key
186      * will be kept in the template.
187      *
188      * @param template template to replace keys with values
189      * @param map map with key value pairs to replace in the template
190      * @return a template with keys replaced
191      */
192     private String replaceKeysFromMap(String template, Map<String, String> map) {
193         final Matcher m = MESSAGE_KEY_PATTERN.matcher(template);
194         final StringBuffer sb = new StringBuffer();
195
196         while (m.find()) {
197             try {
198                 final String key = m.group(1);
199                 m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}')));
200             } catch (RuntimeException e) {
201                 logger.debug("Error occurred during template filling, cause ", e);
202             }
203         }
204         m.appendTail(sb);
205         return sb.toString();
206     }
207 }