diff --git a/application/pom.xml b/application/pom.xml index c9b51c4..ed72c85 100755 --- a/application/pom.xml +++ b/application/pom.xml @@ -42,6 +42,11 @@ webstly-jitsi ${revision} + + de.bstly.we + webstly-jukebox + ${revision} + de.bstly.we webstly-membership diff --git a/core/src/main/java/de/bstly/we/businesslogic/SystemPropertyManager.java b/core/src/main/java/de/bstly/we/businesslogic/SystemPropertyManager.java index 0efb4ff..ab6b93c 100755 --- a/core/src/main/java/de/bstly/we/businesslogic/SystemPropertyManager.java +++ b/core/src/main/java/de/bstly/we/businesslogic/SystemPropertyManager.java @@ -42,12 +42,13 @@ public class SystemPropertyManager { /** * Gets the. * - * @param key the key + * @param key the key * @param defaultValue the default value * @return the string */ public String get(String key, String defaultValue) { - return systemPropertyRepository.findById(key).orElse(new SystemProperty(key, defaultValue)).getValue(); + return systemPropertyRepository.findById(key).orElse(new SystemProperty(key, defaultValue)) + .getValue(); } /** @@ -63,7 +64,7 @@ public class SystemPropertyManager { /** * Gets the boolean. * - * @param key the key + * @param key the key * @param defaultValue the default value * @return the boolean */ @@ -84,7 +85,7 @@ public class SystemPropertyManager { /** * Gets the integer. * - * @param key the key + * @param key the key * @param defaultValue the default value * @return the integer */ @@ -105,7 +106,7 @@ public class SystemPropertyManager { /** * Gets the long. * - * @param key the key + * @param key the key * @param defaultValue the default value * @return the long */ @@ -116,7 +117,7 @@ public class SystemPropertyManager { /** * Adds the. * - * @param key the key + * @param key the key * @param value the value */ public void add(String key, String value) { @@ -128,7 +129,7 @@ public class SystemPropertyManager { /** * Update. * - * @param key the key + * @param key the key * @param value the value */ public void update(String key, String value) { @@ -139,4 +140,12 @@ public class SystemPropertyManager { systemPropertyRepository.save(systemProperty); } + public void set(String key, String value) { + if (systemPropertyRepository.existsById(key)) { + update(key, value); + } else { + add(key, value); + } + } + } diff --git a/jukebox/pom.xml b/jukebox/pom.xml new file mode 100644 index 0000000..3626cf8 --- /dev/null +++ b/jukebox/pom.xml @@ -0,0 +1,21 @@ + + 4.0.0 + + de.bstly.we + webstly-main + ${revision} + + + jukebox + webstly-jukebox + + + + de.bstly.we + webstly-partey + ${revision} + + + diff --git a/jukebox/src/main/java/de/bstly/we/jukebox/businesslogic/JukeboxManager.java b/jukebox/src/main/java/de/bstly/we/jukebox/businesslogic/JukeboxManager.java new file mode 100644 index 0000000..8fc8687 --- /dev/null +++ b/jukebox/src/main/java/de/bstly/we/jukebox/businesslogic/JukeboxManager.java @@ -0,0 +1,280 @@ +/** + * + */ +package de.bstly.we.jukebox.businesslogic; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; + +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import de.bstly.we.businesslogic.SystemPropertyManager; +import de.bstly.we.jukebox.businesslogic.model.JukeboxConfig; +import reactor.core.publisher.Mono; + +/** + * The Class JukeboxManager. + */ +@Component +public class JukeboxManager implements SmartInitializingSingleton { + public static final String CLIENT_ID = "jukebox.client_id"; + public static final String CLIENT_SECRET = "jukebox.client_secret"; + public static final String REDIRECT_URL = "jukebox.redirect_url"; + public static final String ACCOUNT_URL = "jukebox.account_url"; + public static final String API_URL = "jukebox.api_url"; + public static final String ACTIVE = "jukebox.active"; + public static final String ACCESS_TOKEN = "jukebox.access_token"; + public static final String REFRESH_TOKEN = "jukebox.refresh_token"; + public static final String EXPIRES = "jukebox.expires"; + + @Autowired + private SystemPropertyManager systemPropertyManager; + + protected WebClient webClient; + private JukeboxConfig config; + + /* + * @see org.springframework.beans.factory.SmartInitializingSingleton# + * afterSingletonsInstantiated() + */ + /* + * @see org.springframework.beans.factory.SmartInitializingSingleton# + * afterSingletonsInstantiated() + */ + /* + * @see org.springframework.beans.factory.SmartInitializingSingleton# + * afterSingletonsInstantiated() + */ + @Override + public void afterSingletonsInstantiated() { + getConfig(); + + if (StringUtils.hasText(config.getApiUrl())) { + webClient = WebClient.builder().baseUrl(config.getApiUrl()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json").build(); + } + } + + /** + * Gets the config. + * + * @return the config + */ + public JukeboxConfig getConfig() { + if (config == null) { + config = new JukeboxConfig(); + config.setClientId(systemPropertyManager.get(CLIENT_ID)); + config.setClientSecret(systemPropertyManager.get(CLIENT_SECRET)); + config.setRedirectUrl(systemPropertyManager.get(REDIRECT_URL)); + config.setApiUrl(systemPropertyManager.get(API_URL)); + config.setAccountUrl(systemPropertyManager.get(ACCOUNT_URL)); + config.setActive(systemPropertyManager.getBoolean(ACTIVE, false)); + config.setAccessToken(systemPropertyManager.get(ACCESS_TOKEN)); + config.setRefreshToken(systemPropertyManager.get(REFRESH_TOKEN)); + if (StringUtils.hasText(systemPropertyManager.get(EXPIRES))) { + config.setExpires(Instant.parse(systemPropertyManager.get(EXPIRES))); + } + } + + return config; + } + + /** + * Sets the config. + * + * @param config the config + * @return the jukebox config + */ + public JukeboxConfig setConfig(JukeboxConfig config) { + this.config = config; + + systemPropertyManager.set(CLIENT_ID, config.getClientId()); + systemPropertyManager.set(CLIENT_SECRET, config.getClientSecret()); + systemPropertyManager.set(REDIRECT_URL, config.getRedirectUrl()); + systemPropertyManager.set(API_URL, config.getApiUrl()); + systemPropertyManager.set(ACCOUNT_URL, config.getAccountUrl()); + systemPropertyManager.set(ACTIVE, String.valueOf(config.isActive())); + systemPropertyManager.set(ACCESS_TOKEN, config.getAccessToken()); + systemPropertyManager.set(REFRESH_TOKEN, config.getRefreshToken()); + + if (config.getExpires() != null) { + systemPropertyManager.set(EXPIRES, config.getExpires().toString()); + } + + if (StringUtils.hasText(config.getApiUrl())) { + webClient = WebClient.builder().baseUrl(config.getApiUrl()) + .defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json").build(); + } + + return config; + } + + /** + * Check token. + * + * @return true, if successful + */ + protected boolean checkToken() { + if (webClient == null || !StringUtils.hasText(config.getRefreshToken())) { + return false; + } + + if (!StringUtils.hasText(config.getAccessToken()) || config.getExpires() == null + || config.getExpires().isBefore(Instant.now())) { + + String authHeader = "Basic " + + new String(Base64.getEncoder() + .encode(String + .format("%s:%s", config.getClientId(), config.getClientSecret()) + .getBytes())); + WebClient.RequestBodySpec request = WebClient.builder().baseUrl(config.getAccountUrl()) + .build().method(HttpMethod.POST) + .uri(uriBuilder -> uriBuilder.path("/api/token").build()) + .header(HttpHeaders.AUTHORIZATION, authHeader) + .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded"); + + request.bodyValue("grant_type=refresh_token&refresh_token=" + + config.getRefreshToken()); + + String jsonString = request.retrieve().bodyToMono(String.class).block(); + + if (StringUtils.hasText(jsonString)) { + JsonObject response = JsonParser.parseString(jsonString).getAsJsonObject(); + config.setAccessToken(response.get("access_token").getAsString()); + config.setExpires(Instant.now().plus(response.get("expires_in").getAsLong(), + ChronoUnit.SECONDS)); + + if (response.has("refresh_token")) { + config.setRefreshToken(response.get("refresh_token").getAsString()); + } + + return true; + } + } + + return true; + } + + /** + * Gets the status. + * + * @return the status + */ + public JsonElement getStatus() { + if (!checkToken()) { + return null; + } + + WebClient.RequestBodySpec request = webClient.method(HttpMethod.GET) + .uri(uriBuilder -> uriBuilder.path("/v1/me/player").build()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + + config.getAccessToken()); + + String jsonString = request.retrieve().bodyToMono(String.class).block(); + + if (StringUtils.hasText(jsonString)) { + JsonElement status = JsonParser.parseString(jsonString); + if (config.isActive()) { + boolean disable = false; + if (status == null || status.isJsonNull() || !status.isJsonObject()) { + disable = true; + } else { + JsonObject statusObject = status.getAsJsonObject(); + if (!statusObject.has("device")) { + disable = true; + } + JsonObject device = statusObject.getAsJsonObject("device"); + if (!device.has("is_private_session") + || !device.get("is_private_session").getAsBoolean()) { + disable = true; + } + } + if (disable) { + config.setActive(false); + systemPropertyManager.set(ACTIVE, String.valueOf(config.isActive())); + } + } + return status; + } + + return JsonNull.INSTANCE; + } + + /** + * Search track. + * + * @param query the query + * @param offset the offset + * @return the json element + */ + public JsonElement searchTrack(String query, Long offset) { + if (!checkToken()) { + return null; + } + + MultiValueMap queryParameters = new LinkedMultiValueMap(); + queryParameters.add("q", query); + queryParameters.add("type", "track"); + if (offset != null) { + queryParameters.add("offset", String.valueOf(offset)); + } + WebClient.RequestBodySpec request = webClient.method(HttpMethod.GET).uri( + uriBuilder -> uriBuilder.path("/v1/search").queryParams(queryParameters).build()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + + config.getAccessToken()); + + String jsonString = request.retrieve().bodyToMono(String.class) + .onErrorResume(e -> Mono.just(e.getMessage())).block(); + + if (StringUtils.hasText(jsonString)) { + return JsonParser.parseString(jsonString); + } + + return null; + } + + /** + * Adds the to queue. + * + * @param uri the uri + */ + public void addToQueue(String uri) { + if (!checkToken()) { + return; + } + + MultiValueMap queryParameters = new LinkedMultiValueMap(); + queryParameters.add("uri", uri); + WebClient.RequestBodySpec request = webClient.method(HttpMethod.POST) + .uri(uriBuilder -> uriBuilder.path("/v1/me/player/queue") + .queryParams(queryParameters).build()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + + config.getAccessToken()); + + request.retrieve().bodyToMono(String.class).block(); + } + + /** + * Check active. + */ + @Scheduled(cron = "${we.bstly.jukebox.cron:0 */5 0 * * * }") + public void checkActive() { + getStatus(); + } + +} diff --git a/jukebox/src/main/java/de/bstly/we/jukebox/businesslogic/model/JukeboxConfig.java b/jukebox/src/main/java/de/bstly/we/jukebox/businesslogic/model/JukeboxConfig.java new file mode 100644 index 0000000..04084a1 --- /dev/null +++ b/jukebox/src/main/java/de/bstly/we/jukebox/businesslogic/model/JukeboxConfig.java @@ -0,0 +1,181 @@ +/** + * + */ +package de.bstly.we.jukebox.businesslogic.model; + +import java.time.Instant; + +/** + * The Class JukeboxConfig. + */ +public class JukeboxConfig { + + private String clientId; + private String clientSecret; + private String redirectUrl; + private String apiUrl; + private String accountUrl; + private boolean active; + private String accessToken; + private String refreshToken; + private Instant expires; + + /** + * Gets the client id. + * + * @return the client id + */ + public String getClientId() { + return clientId; + } + + /** + * Sets the client id. + * + * @param clientId the new client id + */ + public void setClientId(String clientId) { + this.clientId = clientId; + } + + /** + * Gets the client secret. + * + * @return the client secret + */ + public String getClientSecret() { + return clientSecret; + } + + /** + * Sets the client secret. + * + * @param clientSecret the new client secret + */ + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + /** + * Gets the redirect url. + * + * @return the redirect url + */ + public String getRedirectUrl() { + return redirectUrl; + } + + /** + * Sets the redirect url. + * + * @param redirectUrl the new redirect url + */ + public void setRedirectUrl(String redirectUrl) { + this.redirectUrl = redirectUrl; + } + + /** + * Gets the api url. + * + * @return the api url + */ + public String getApiUrl() { + return apiUrl; + } + + /** + * Sets the api url. + * + * @param apiUrl the new api url + */ + public void setApiUrl(String apiUrl) { + this.apiUrl = apiUrl; + } + + /** + * @return the accountUrl + */ + public String getAccountUrl() { + return accountUrl; + } + + /** + * @param accountUrl the accountUrl to set + */ + public void setAccountUrl(String accountUrl) { + this.accountUrl = accountUrl; + } + + /** + * Checks if is active. + * + * @return true, if is active + */ + public boolean isActive() { + return active; + } + + /** + * Sets the active. + * + * @param active the new active + */ + public void setActive(boolean active) { + this.active = active; + } + + /** + * Gets the access token. + * + * @return the access token + */ + public String getAccessToken() { + return accessToken; + } + + /** + * Sets the access token. + * + * @param accessToken the new access token + */ + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + /** + * Gets the refresh token. + * + * @return the refresh token + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Sets the refresh token. + * + * @param refreshToken the new refresh token + */ + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + /** + * Gets the expires. + * + * @return the expires + */ + public Instant getExpires() { + return expires; + } + + /** + * Sets the expires. + * + * @param expires the new expires + */ + public void setExpires(Instant expires) { + this.expires = expires; + } + +} diff --git a/jukebox/src/main/java/de/bstly/we/jukebox/controller/JukeboxController.java b/jukebox/src/main/java/de/bstly/we/jukebox/controller/JukeboxController.java new file mode 100644 index 0000000..0a6408f --- /dev/null +++ b/jukebox/src/main/java/de/bstly/we/jukebox/controller/JukeboxController.java @@ -0,0 +1,143 @@ +/** + * + */ +package de.bstly.we.jukebox.controller; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.Optional; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.beust.jcommander.internal.Maps; +import com.google.gson.Gson; +import com.google.gson.JsonIOException; + +import de.bstly.we.businesslogic.PermissionManager; +import de.bstly.we.controller.BaseController; +import de.bstly.we.controller.support.EntityResponseStatusException; +import de.bstly.we.jukebox.businesslogic.JukeboxManager; +import de.bstly.we.partey.businesslogic.ParteyPermissions; + +/** + * The Class JukeboxController. + */ + +@RestController +@RequestMapping("/jukebox") +public class JukeboxController extends BaseController { + + @Autowired + private PermissionManager permissionManager; + @Autowired + private JukeboxManager jukeboxManager; + private Gson gson = new Gson(); + + private Map queueList = Maps.newHashMap(); + private Map searchList = Maps.newHashMap(); + + /** + * Check queue permission. + */ + protected void checkQueuePermission() { + if (!permissionManager.hasPermission(getCurrentUserId(), ParteyPermissions.PARTEY)) { + throw new EntityResponseStatusException(HttpStatus.FORBIDDEN); + } + + if (!jukeboxManager.getConfig().isActive()) { + throw new EntityResponseStatusException(HttpStatus.GONE); + } + + jukeboxManager.getStatus(); + + if (!jukeboxManager.getConfig().isActive()) { + throw new EntityResponseStatusException(HttpStatus.GONE); + } + + if (queueList.containsKey(getCurrentUserId()) && queueList.get(getCurrentUserId()) + .isAfter(Instant.now().minus(1, ChronoUnit.MINUTES))) { + throw new EntityResponseStatusException( + Duration.between(queueList.get(getCurrentUserId()), Instant.now()).getSeconds(), + HttpStatus.PAYMENT_REQUIRED); + } + } + + /** + * Check search permission. + */ + protected void checkSearchPermission() { + if (!permissionManager.hasPermission(getCurrentUserId(), ParteyPermissions.PARTEY)) { + throw new EntityResponseStatusException(HttpStatus.FORBIDDEN); + } + + if (!jukeboxManager.getConfig().isActive()) { + throw new EntityResponseStatusException(HttpStatus.GONE); + } + + if (searchList.containsKey(getCurrentUserId()) && searchList.get(getCurrentUserId()) + .isAfter(Instant.now().minus(3, ChronoUnit.SECONDS))) { + throw new EntityResponseStatusException(Duration + .between(searchList.get(getCurrentUserId()), Instant.now()).getSeconds(), + HttpStatus.PAYMENT_REQUIRED); + } + } + + /** + * Check. + * + * @throws JsonIOException the json IO exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @PreAuthorize("isAuthenticated()") + @GetMapping + public void check() throws JsonIOException, IOException { + checkQueuePermission(); + } + + /** + * Search. + * + * @param query the query + * @param offset the offset + * @param response the response + * @throws JsonIOException the json IO exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @PreAuthorize("isAuthenticated()") + @GetMapping("/search") + public void search(@RequestParam("q") String query, + @RequestParam("offset") Optional offset, HttpServletResponse response) + throws JsonIOException, IOException { + checkSearchPermission(); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + gson.toJson(jukeboxManager.searchTrack(query, offset.orElse(null)), response.getWriter()); + searchList.put(getCurrentUserId(), Instant.now()); + } + + /** + * Queue. + * + * @param uri the uri + */ + @PreAuthorize("isAuthenticated()") + @PostMapping("/queue") + public void queue(@RequestParam("uri") String uri) { + checkQueuePermission(); + jukeboxManager.addToQueue(uri); + queueList.put(getCurrentUserId(), Instant.now()); + } + +} diff --git a/jukebox/src/main/java/de/bstly/we/jukebox/controller/JukeboxManagementController.java b/jukebox/src/main/java/de/bstly/we/jukebox/controller/JukeboxManagementController.java new file mode 100644 index 0000000..2613da4 --- /dev/null +++ b/jukebox/src/main/java/de/bstly/we/jukebox/controller/JukeboxManagementController.java @@ -0,0 +1,138 @@ +/** + * + */ +package de.bstly.we.jukebox.controller; + +import java.io.IOException; +import java.util.Optional; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.google.gson.Gson; +import com.google.gson.JsonIOException; + +import de.bstly.we.controller.BaseController; +import de.bstly.we.jukebox.businesslogic.JukeboxManager; +import de.bstly.we.jukebox.businesslogic.model.JukeboxConfig; + +/** + * The Class JukeboxManagementController. + */ + +@RestController +@RequestMapping("/jukebox/manage") +public class JukeboxManagementController extends BaseController { + + @Autowired + private JukeboxManager jukeboxManager; + private Gson gson = new Gson(); + + /** + * Gets the config. + * + * @return the config + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/config") + public JukeboxConfig getConfig() { + return jukeboxManager.getConfig(); + } + + /** + * Sets the config. + * + * @param config the config + * @return the jukebox config + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/config") + public JukeboxConfig setConfig(@RequestBody JukeboxConfig config) { + return jukeboxManager.setConfig(config); + } + + /** + * Sets the config. + * + * @param config the config + * @return the jukebox config + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PutMapping + public void setActive() { + JukeboxConfig config = getConfig(); + config.setActive(true); + jukeboxManager.setConfig(config); + } + + /** + * Sets the config. + * + * @param config the config + * @return the jukebox config + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping + public void disable() { + JukeboxConfig config = getConfig(); + config.setActive(false); + jukeboxManager.setConfig(config); + } + + /** + * Gets the status. + * + * @param response the response + * @return the status + * @throws JsonIOException the json IO exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping() + public void getStatus(HttpServletResponse response) throws JsonIOException, IOException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + gson.toJson(jukeboxManager.getStatus(), response.getWriter()); + } + + /** + * Search. + * + * @param query the query + * @param offset the offset + * @param response the response + * @throws JsonIOException the json IO exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/search") + public void search(@RequestParam("q") String query, + @RequestParam("offset") Optional offset, HttpServletResponse response) + throws JsonIOException, IOException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + gson.toJson(jukeboxManager.searchTrack(query, offset.orElse(null)), response.getWriter()); + } + + /** + * Queue. + * + * @param uri the uri + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/queue") + public void queue(@RequestParam("uri") String uri) { + jukeboxManager.addToQueue(uri); + } + +} diff --git a/pom.xml b/pom.xml index a42d21a..b199dd7 100755 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ UTF-8 11 - 1.4.2-SNAPSHOT + 1.4.3-SNAPSHOT @@ -30,6 +30,7 @@ i18n invite jitsi + jukebox jwt membership minetest