2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.spotify.internal;
15 import java.io.IOException;
16 import java.nio.charset.StandardCharsets;
17 import java.util.HashMap;
18 import java.util.List;
20 import java.util.regex.Matcher;
21 import java.util.regex.Pattern;
22 import java.util.stream.Collectors;
24 import javax.servlet.ServletException;
25 import javax.servlet.http.HttpServlet;
26 import javax.servlet.http.HttpServletRequest;
27 import javax.servlet.http.HttpServletResponse;
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;
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.
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.
46 public class SpotifyAuthServlet extends HttpServlet {
48 private static final long serialVersionUID = -4719613645562518231L;
50 private static final String CONTENT_TYPE = "text/html;charset=UTF-8";
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>";
57 private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}");
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";
73 private final Logger logger = LoggerFactory.getLogger(SpotifyAuthServlet.class);
74 private final SpotifyAuthService spotifyAuthService;
75 private final String indexTemplate;
76 private final String playerTemplate;
78 public SpotifyAuthServlet(SpotifyAuthService spotifyAuthService, String indexTemplate, String playerTemplate) {
79 this.spotifyAuthService = spotifyAuthService;
80 this.indexTemplate = indexTemplate;
81 this.playerTemplate = playerTemplate;
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<>();
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();
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
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
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, "");
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");
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)) {
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()));
140 * Formats the HTML of all available Spotify Bridge Players and returns it as a String
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
146 private String formatPlayers(String playerTemplate, String servletBaseURL) {
147 final List<SpotifyAccountHandler> players = spotifyAuthService.getSpotifyAccountHandlers();
149 return players.isEmpty() ? HTML_EMPTY_PLAYERS
150 : players.stream().map(p -> formatPlayer(playerTemplate, p, servletBaseURL))
151 .collect(Collectors.joining());
155 * Formats the HTML of a Spotify Bridge Player and returns it as a String
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
162 private String formatPlayer(String playerTemplate, SpotifyAccountHandler handler, String servletBaseURL) {
163 final Map<String, String> map = new HashMap<>();
165 map.put(PLAYER_ID, handler.getUID().getAsString());
166 map.put(PLAYER_NAME, handler.getLabel());
167 final String spotifyUser = handler.getUser();
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));
176 map.put(PLAYER_AUTHORIZED_CLASS, "");
177 map.put(PLAYER_SPOTIFY_USER_ID, "");
180 map.put(PLAYER_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL));
181 return replaceKeysFromMap(playerTemplate, map);
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.
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
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();
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);
205 return sb.toString();