diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..cdc7952 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,23 @@ +# [Choice] Java version (use -bullseye variants on local arm64/Apple Silicon): 11, 17, 11-bullseye, 17-bullseye, 11-buster, 17-buster +ARG VARIANT=11-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/java:0-${VARIANT} + +# [Option] Install Maven +ARG INSTALL_MAVEN="false" +ARG MAVEN_VERSION="" +# [Option] Install Gradle +ARG INSTALL_GRADLE="false" +ARG GRADLE_VERSION="" +RUN if [ "${INSTALL_MAVEN}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/sdkman/bin/sdkman-init.sh && sdk install maven \"${MAVEN_VERSION}\""; fi \ + && if [ "${INSTALL_GRADLE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/sdkman/bin/sdkman-init.sh && sdk install gradle \"${GRADLE_VERSION}\""; fi + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..09881c4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +{ + "name": "Java & Mariadb", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + + // Set *default* container specific settings.json values on container create. + "settings": { + "java.jdt.ls.java.home": "/docker-java-home" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "vscjava.vscode-java-pack", + "pivotal.vscode-boot-dev-pack", + "richardwillis.vscode-gradle-extension-pack", + "eamodio.gitlens", + "donjayamanne.githistory" + ], + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or with the host. + // "forwardPorts": [5432], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "java -version", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..f5079f7 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,48 @@ +version: '3.8' + +volumes: + mysql-data: +services: + app: + container_name: javadev + build: + context: . + dockerfile: Dockerfile + args: + # Update 'VARIANT' to pick an version of Java: 11, 17. + # Append -bullseye or -buster to pin to an OS version. + # Use -bullseye variants on local arm64/Apple Silicon. + VARIANT: "17" + # Options + INSTALL_MAVEN: "false" + MAVEN_VERSION: "" + INSTALL_GRADLE: "true" + GRADLE_VERSION: "7.4.2" + NODE_VERSION: "lts/*" + volumes: + - ..:/workspace:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + # Uncomment the next line to use a non-root user for all processes. + # user: vscode + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + container_name: database + image: mariadb:latest + restart: unless-stopped + volumes: + - mysql-data:/var/lib/mysql + environment: + MARIADB_ROOT_PASSWORD: mariadb_root + MARIADB_PASSWORD: claptrap + MARIADB_USER: claptrap + MARIADB_DATABASE: claptrap + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ef99f41..68d3229 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,10 @@ name: Build on: push: + branches-ignore: + - "renovate/**" + tags-ignore: + - "**" jobs: build-gradle: @@ -12,11 +16,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up JDK 17 - uses: actions/setup-java@v1 + uses: actions/setup-java@v3 with: + distribution: 'temurin' java-version: 17 - name: Grant execute permission for gradlew @@ -36,10 +41,10 @@ jobs: needs: - build-gradle steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Download artifact - uses: actions/download-artifact@v1.0.0 + uses: actions/download-artifact@v3.0.0 with: # Artifact name name: claptrap_jar @@ -47,10 +52,10 @@ jobs: path: build/libs/ - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Login to ghcr.io - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -58,20 +63,14 @@ jobs: - name: Get branch name id: branch-name - uses: tj-actions/branch-names@v5.2 - - - name: Set tag master - if: steps.branch-name.outputs.current_branch == 'master' - run: | - echo "tag=latest" >> $GITHUB_ENV + uses: tj-actions/branch-names@v5.3 - name: Set tag - if: steps.branch-name.outputs.current_branch != 'master' run: | echo "tag=${{ steps.branch-name.outputs.current_branch }}" >> $GITHUB_ENV - name: Build and push Docker - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: push: true context: . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..99a2c60 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +# This workflow will build a Java project with Gradle +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle + +name: Build Release + +on: + push: + tags: + - "**" + +jobs: + build-gradle: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build + + - name: Upload Jar File + uses: actions/upload-artifact@v2-preview + with: + name: claptrap_jar + path: build/libs/ + + build-docker: + runs-on: ubuntu-latest + needs: + - build-gradle + steps: + - uses: actions/checkout@v3 + + - name: Download artifact + uses: actions/download-artifact@v3.0.0 + with: + # Artifact name + name: claptrap_jar + # Destination path + path: build/libs/ + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to ghcr.io + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.CR_PAT }} + + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v5.3 + + - name: Set tag + run: | + echo "tag=${{ steps.branch-name.outputs.tag }}" >> $GITHUB_ENV + + - name: Build and push Docker + uses: docker/build-push-action@v3 + with: + push: true + context: . + tags: | + ghcr.io/sebclem/claptrapbot:${{ env.tag }} + ghcr.io/sebclem/claptrapbot:latest + file: ./Dockerfile diff --git a/.gitignore b/.gitignore index cca9102..a951070 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,11 @@ src/main/resources/templates/js src/main/resources/static/error/css src/main/resources/static/error/js + + +**.log +.jpb/ + +**/*.env + +bin/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e66ba23 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "Launch MainBot", + "request": "launch", + "mainClass": "net.Broken.MainBot", + "projectName": "ClaptrapBot", + "envFile": "${workspaceFolder}/.env" + }, + + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index df12a14..9ae49ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM openjdk:17.0.2 +FROM openjdk:18.0.1 WORKDIR /bot_src ARG BUILD_NBR ARG BRANCH_NAME ARG BRANCH_NAME ARG GITHUB_RUN_NUMBER -ADD build/libs/ClaptrapBot-*.jar /bot_src/bot.jar +ADD build/libs/ClaptrapBot.jar /bot_src/claptrapbot.jar RUN java -version -CMD java -jar bot.jar +CMD java -jar claptrapbot.jar LABEL org.opencontainers.image.source=https://github.com/Sebclem/ClaptrapBot/ \ No newline at end of file diff --git a/README.md b/README.md index 1513c9b..1f8f206 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ # ClaptrapBot: A multifunctional Discord Bot ! + [![GitHub Release][releases-shield]][releases] ![Project Stage][project-stage-shield] [![License][license-shield]](LICENSE.md) diff --git a/build.gradle b/build.gradle index 589b6a8..c31982b 100644 --- a/build.gradle +++ b/build.gradle @@ -3,18 +3,15 @@ plugins { id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' id 'groovy' + id 'org.liquibase.gradle' version '2.1.1' + id "nebula.lint" version "17.7.0" + id "com.gorylenko.gradle-git-properties" version "2.4.1" } - -def versionObj = new Version(major: 0, minor: 2, revision: 0) - group = "net.broken" archivesBaseName = "ClaptrapBot" -version = "$versionObj" - sourceCompatibility = '17' - repositories { mavenCentral() maven { @@ -25,48 +22,66 @@ jar { enabled(false) } +configurations.implementation { + exclude group: "org.springframework.boot", module: "spring-boot-starter-logging" +} + dependencies { - implementation("org.springframework.boot:spring-boot-starter-web") { - exclude group: "org.springframework.boot", module: "spring-boot-starter-logging" - } + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-log4j2") - implementation 'org.codehaus.groovy:groovy-all:3.0.8' + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation('org.springframework.boot:spring-boot-starter-actuator') + implementation('io.micrometer:micrometer-registry-prometheus:1.9.1') + implementation('org.springdoc:springdoc-openapi-ui:1.6.9') + implementation('org.springdoc:springdoc-openapi-security:1.6.9') + + implementation('org.liquibase:liquibase-core') + + implementation('io.jsonwebtoken:jjwt-api:0.11.5') + implementation('io.jsonwebtoken:jjwt-impl:0.11.5') + implementation('io.jsonwebtoken:jjwt-jackson:0.11.5') + + implementation('com.sedmelluq:lavaplayer:1.3.78') + implementation('net.dv8tion:JDA:4.4.0_350') + + implementation(platform("org.apache.logging.log4j:log4j-bom:2.17.2")) + implementation group: 'org.hibernate', name: 'hibernate-validator', version: '7.0.4.Final' - implementation 'com.sedmelluq:lavaplayer:1.3.77' - implementation 'net.dv8tion:JDA:4.4.0_350' - implementation group: 'org.json', name: 'json', version: '20210307' - implementation 'org.springframework.security:spring-security-web:5.5.0' -// JPA Data (We are going to use Repositories, Entities, Hibernate, etc...) - implementation("org.springframework.boot:spring-boot-starter-data-jpa") { - exclude group: "org.springframework.boot", module: "spring-boot-starter-logging" - } - implementation(platform("org.apache.logging.log4j:log4j-bom:2.17.1")) // Use MySQL Connector-J - implementation 'mysql:mysql-connector-java' - implementation 'org.reflections:reflections:0.9.12' - implementation 'org.apache.commons:commons-lang3:3.12.0' - implementation 'com.google.api-client:google-api-client:1.31.5' - implementation 'com.google.apis:google-api-services-youtube:v3-rev20210410-1.31.0' + implementation('mysql:mysql-connector-java:8.0.29') + implementation('org.reflections:reflections:0.10.2') + implementation('org.apache.commons:commons-lang3:3.12.0') - implementation group: 'org.jsoup', name: 'jsoup', version: '1.13.1' + liquibaseRuntime('org.liquibase:liquibase-core:4.12.0') + liquibaseRuntime('org.liquibase:liquibase-groovy-dsl:3.0.2') + liquibaseRuntime('mysql:mysql-connector-java:8.0.29') + liquibaseRuntime group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.1' + liquibaseRuntime group: 'org.liquibase.ext', name: 'liquibase-hibernate5', version: '4.12.0' + liquibaseRuntime 'org.springframework.boot:spring-boot-starter-data-jpa' + liquibaseRuntime 'org.springframework.data:spring-data-jpa' + liquibaseRuntime 'org.springframework:spring-beans' + liquibaseRuntime 'net.dv8tion:JDA:4.4.0_350' + liquibaseRuntime 'com.sedmelluq:lavaplayer:1.3.78' + liquibaseRuntime sourceSets.main.output +} +apply plugin: "org.liquibase.gradle" - implementation("org.springframework.boot:spring-boot-starter-thymeleaf") { - exclude group: "org.springframework.boot", module: "spring-boot-starter-logging" - } -} - -class Version { - String major, minor, revision - - static String getBuild() { - System.getenv("GITHUB_RUN_NUMBER") ?: System.getProperty("BUILD_NUMBER") ?: - System.getenv("GIT_COMMIT")?.substring(0, 7) ?: System.getProperty("GIT_COMMIT")?.substring(0, 7) ?: "DEV" - } - - String toString() { - "${major}.${minor}.${revision}_$build" +configurations { + liquibaseRuntime.extendsFrom runtime +} +liquibase { + activities { + main { + changeLogFile "src/main/resources/db/changelog/db.changelog-master.yml" + url System.getenv("DB_URL") + referenceUrl 'hibernate:spring:net.Broken?dialect=org.hibernate.dialect.MySQL5Dialect&hibernate.physical_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy&hibernate.implicit_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy' + username System.getenv("DB_USER") + password System.getenv("DB_PWD") + } } } diff --git a/renovate.json b/renovate.json index f45d8f1..19c1539 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,6 @@ { "extends": [ "config:base" - ] + ], + "commitMessagePrefix": ":arrow_up:" } diff --git a/src/main/java/net/Broken/Api/Controllers/AudioController.java b/src/main/java/net/Broken/Api/Controllers/AudioController.java new file mode 100644 index 0000000..ee7bce0 --- /dev/null +++ b/src/main/java/net/Broken/Api/Controllers/AudioController.java @@ -0,0 +1,83 @@ +package net.Broken.Api.Controllers; + +import net.Broken.Api.Data.Music.Add; +import net.Broken.Api.Data.Music.Connect; +import net.Broken.Api.Data.Music.Status; +import net.Broken.Api.Security.Data.JwtPrincipal; +import net.Broken.Api.Services.AudioService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.concurrent.ExecutionException; + +@RestController +@RequestMapping("/api/v2/audio") +@CrossOrigin(origins = "*", maxAge = 3600) +public class AudioController { + + public final AudioService audioService; + + public AudioController(AudioService audioService) { + this.audioService = audioService; + } + + + @GetMapping("/{guildId}/status") + @PreAuthorize("isInGuild(#guildId)") + public Status getMusicStatus(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.getGuildAudioStatus(guildId, principal.user().getDiscordId()); + } + + + @PostMapping("/{guildId}/connect") + @PreAuthorize("isInGuild(#guildId) && canInteractWithVoiceChannel(#guildId, #body)") + public ResponseEntity connect(@PathVariable String guildId, @RequestBody Connect body, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.connect(guildId, body, principal.user().getDiscordId()); + } + + @PostMapping("/{guildId}/disconnect") + @PreAuthorize("isInGuild(#guildId) && canInteractWithVoiceChannel(#guildId)") + public ResponseEntity disconnect(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.disconnect(guildId, principal.user().getDiscordId()); + } + + @PostMapping("/{guildId}/resume") + @PreAuthorize("isInGuild(#guildId) && canInteractWithVoiceChannel(#guildId)") + public ResponseEntity resume(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.resume(guildId, principal.user().getDiscordId()); + } + + @PostMapping("/{guildId}/pause") + @PreAuthorize("isInGuild(#guildId) && canInteractWithVoiceChannel(#guildId)") + public ResponseEntity pause(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.pause(guildId, principal.user().getDiscordId()); + } + + @PostMapping("/{guildId}/skip") + @PreAuthorize("isInGuild(#guildId) && canInteractWithVoiceChannel(#guildId)") + public ResponseEntity skip(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.skip(guildId, principal.user().getDiscordId()); + } + + @PostMapping("/{guildId}/stop") + @PreAuthorize("isInGuild(#guildId) && canInteractWithVoiceChannel(#guildId)") + public ResponseEntity stop(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.stop(guildId, principal.user().getDiscordId()); + } + + @PostMapping("/{guildId}/add") + @PreAuthorize("isInGuild(#guildId) && canInteractWithVoiceChannel(#guildId)") + public ResponseEntity add(@PathVariable String guildId, @RequestBody Add body, Authentication authentication) throws ExecutionException, InterruptedException { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return audioService.add(guildId, principal.user().getDiscordId(), body); + } +} diff --git a/src/main/java/net/Broken/Api/Controllers/AuthController.java b/src/main/java/net/Broken/Api/Controllers/AuthController.java new file mode 100644 index 0000000..75d6c43 --- /dev/null +++ b/src/main/java/net/Broken/Api/Controllers/AuthController.java @@ -0,0 +1,40 @@ +package net.Broken.Api.Controllers; + +import net.Broken.Api.Data.Login; +import net.Broken.Api.Security.Data.JwtResponse; +import net.Broken.Api.Security.Services.JwtService; +import net.Broken.DB.Entity.UserEntity; +import net.Broken.DB.Repository.UserRepository; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v2/auth") +@CrossOrigin(origins = "*", maxAge = 3600) +public class AuthController { + private final AuthenticationManager authenticationManager; + + private final JwtService jwtService; + + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtService jwtService) { + this.authenticationManager = authenticationManager; + this.jwtService = jwtService; + } + + @PostMapping("/discord") + public JwtResponse loginDiscord(@Validated @RequestBody Login login) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(login.redirectUri(), login.code()) + ); + + UserEntity user = (UserEntity) authentication.getPrincipal(); + + String jwt = jwtService.buildJwt(user); + + + return new JwtResponse(jwt); + } +} diff --git a/src/main/java/net/Broken/Api/Controllers/CrossOptionController.java b/src/main/java/net/Broken/Api/Controllers/CrossOptionController.java new file mode 100644 index 0000000..69b7b24 --- /dev/null +++ b/src/main/java/net/Broken/Api/Controllers/CrossOptionController.java @@ -0,0 +1,28 @@ +package net.Broken.Api.Controllers; + +import io.swagger.v3.oas.annotations.Hidden; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("/api/v2") +@CrossOrigin(origins = "*", maxAge = 3600) +@Hidden +public class CrossOptionController { + + /** + * For cross preflight request send by axios + */ + @RequestMapping( + value = "/**", + method = RequestMethod.OPTIONS + ) + public ResponseEntity handle() { + return new ResponseEntity<>("",HttpStatus.OK); + } +} diff --git a/src/main/java/net/Broken/Api/Controllers/GuildController.java b/src/main/java/net/Broken/Api/Controllers/GuildController.java new file mode 100644 index 0000000..a0bc11e --- /dev/null +++ b/src/main/java/net/Broken/Api/Controllers/GuildController.java @@ -0,0 +1,59 @@ +package net.Broken.Api.Controllers; + +import net.Broken.Api.Data.Guild.Channel; +import net.Broken.Api.Data.Guild.Guild; +import net.Broken.Api.Data.Guild.Role; +import net.Broken.Api.Data.InviteLink; +import net.Broken.Api.Security.Data.JwtPrincipal; +import net.Broken.Api.Services.GuildService; +import net.Broken.MainBot; +import net.dv8tion.jda.api.Permission; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v2/guild") +@CrossOrigin(origins = "*", maxAge = 3600) +public class GuildController { + + public final GuildService guildService; + + public GuildController(GuildService guildService) { + this.guildService = guildService; + } + + @GetMapping("mutual") + public List getMutualGuilds(Authentication authentication) { + JwtPrincipal jwtPrincipal = (JwtPrincipal) authentication.getPrincipal(); + return guildService.getMutualGuilds(jwtPrincipal.user()); + } + + @GetMapping("inviteLink") + public InviteLink getInviteLink() { + + return new InviteLink(guildService.getInviteLink()); + } + + @GetMapping("/{guildId}/voiceChannels") + @PreAuthorize("isInGuild(#guildId)") + public List getVoiceChannels(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return guildService.getVoiceChannel(guildId, principal.user().getDiscordId()); + } + + @GetMapping("/{guildId}/textChannels") + @PreAuthorize("isInGuild(#guildId)") + public List getTextChannels(@PathVariable String guildId, Authentication authentication) { + JwtPrincipal principal = (JwtPrincipal) authentication.getPrincipal(); + return guildService.getTextChannel(guildId, principal.user().getDiscordId()); + } + + @GetMapping("/{guildId}/roles") + @PreAuthorize("isInGuild(#guildId)") + public List getRoles(@PathVariable String guildId) { + return guildService.getRole(guildId); + } +} diff --git a/src/main/java/net/Broken/Api/Controllers/SettingController.java b/src/main/java/net/Broken/Api/Controllers/SettingController.java new file mode 100644 index 0000000..2cbe1f6 --- /dev/null +++ b/src/main/java/net/Broken/Api/Controllers/SettingController.java @@ -0,0 +1,40 @@ +package net.Broken.Api.Controllers; + +import net.Broken.Api.Data.Settings.SettingGroup; +import net.Broken.Api.Data.Settings.Value; +import net.Broken.Api.Services.SettingService; +import net.Broken.DB.Entity.GuildPreferenceEntity; +import net.Broken.Tools.Settings.SettingValueBuilder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v2/setting") +@CrossOrigin(origins = "*", maxAge = 3600) +public class SettingController { + public final SettingService settingService; + + public SettingController(SettingService settingService) { + this.settingService = settingService; + } + + @GetMapping("description") + public List getSettingDescription(){ + return settingService.getSettingDescription(); + } + + @GetMapping("/{guildId}/values") + @PreAuthorize("isInGuild(#guildId) && canManageGuild(#guildId)") + public List getSettingValues(@PathVariable String guildId){ + return settingService.getValues(guildId); + } + + @PostMapping("/{guildId}/values") + @PreAuthorize("isInGuild(#guildId) && canManageGuild(#guildId)") + public List getSettingValues(@PathVariable String guildId, @RequestBody List values){ + GuildPreferenceEntity pref = settingService.saveValue(guildId, values); + return new SettingValueBuilder(pref).build(); + } +} diff --git a/src/main/java/net/Broken/Api/Data/Guild/Channel.java b/src/main/java/net/Broken/Api/Data/Guild/Channel.java new file mode 100644 index 0000000..557c2bb --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Guild/Channel.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Data.Guild; + +public record Channel(String id, String name) { +} diff --git a/src/main/java/net/Broken/Api/Data/Guild/Guild.java b/src/main/java/net/Broken/Api/Data/Guild/Guild.java new file mode 100644 index 0000000..5f33ea5 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Guild/Guild.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Data.Guild; + +public record Guild(String id, String name, String iconUrl, boolean canManage) { +} diff --git a/src/main/java/net/Broken/Api/Data/Guild/Role.java b/src/main/java/net/Broken/Api/Data/Guild/Role.java new file mode 100644 index 0000000..473eedd --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Guild/Role.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Data.Guild; + +public record Role(String id, String name) { +} diff --git a/src/main/java/net/Broken/Api/Data/InviteLink.java b/src/main/java/net/Broken/Api/Data/InviteLink.java new file mode 100644 index 0000000..f98926d --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/InviteLink.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Data; + +public record InviteLink(String link) { +} diff --git a/src/main/java/net/Broken/Api/Data/Login.java b/src/main/java/net/Broken/Api/Data/Login.java new file mode 100644 index 0000000..ffa13f1 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Login.java @@ -0,0 +1,8 @@ +package net.Broken.Api.Data; + +import javax.validation.constraints.NotBlank; + +public record Login( + @NotBlank String code, @NotBlank String redirectUri) { + +} diff --git a/src/main/java/net/Broken/Api/Data/Music/Add.java b/src/main/java/net/Broken/Api/Data/Music/Add.java new file mode 100644 index 0000000..c48c56d --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Music/Add.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Data.Music; + +public record Add(String url) { +} diff --git a/src/main/java/net/Broken/Api/Data/Music/Connect.java b/src/main/java/net/Broken/Api/Data/Music/Connect.java new file mode 100644 index 0000000..112d307 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Music/Connect.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Data.Music; + +public record Connect(String channelId) { +} diff --git a/src/main/java/net/Broken/Api/Data/Music/PlayBackInfo.java b/src/main/java/net/Broken/Api/Data/Music/PlayBackInfo.java new file mode 100644 index 0000000..67079a0 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Music/PlayBackInfo.java @@ -0,0 +1,12 @@ +package net.Broken.Api.Data.Music; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PlayBackInfo( + Boolean paused, + Boolean stopped, + Long progress, + TrackInfo trackInfo +) { +} diff --git a/src/main/java/net/Broken/Api/Data/Music/Status.java b/src/main/java/net/Broken/Api/Data/Music/Status.java new file mode 100644 index 0000000..99fafa9 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Music/Status.java @@ -0,0 +1,16 @@ +package net.Broken.Api.Data.Music; + +import com.fasterxml.jackson.annotation.JsonInclude; +import net.Broken.Api.Data.Guild.Channel; +import net.dv8tion.jda.api.audio.hooks.ConnectionStatus; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record Status( + Boolean connected, + ConnectionStatus connectionStatus, + Channel channel, + Boolean canView, + Boolean canInteract, + PlayBackInfo playBackInfo +) { +} diff --git a/src/main/java/net/Broken/Api/Data/Music/TrackInfo.java b/src/main/java/net/Broken/Api/Data/Music/TrackInfo.java new file mode 100644 index 0000000..1d99679 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Music/TrackInfo.java @@ -0,0 +1,13 @@ +package net.Broken.Api.Data.Music; + +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import net.Broken.Api.Data.UserInfo; +import net.Broken.Audio.UserAudioTrack; + +public record TrackInfo(UserInfo submitter, AudioTrackInfo detail) { + + public TrackInfo(UserAudioTrack userAudioTrack) { + this(new UserInfo(userAudioTrack.getSubmittedUser().getId(), userAudioTrack.getSubmittedUser().getName(), userAudioTrack.getSubmittedUser().getAvatarUrl()), + userAudioTrack.getAudioTrack().getInfo()); + } +} diff --git a/src/main/java/net/Broken/Api/Data/Settings/SettingDescriber.java b/src/main/java/net/Broken/Api/Data/Settings/SettingDescriber.java new file mode 100644 index 0000000..16dc5f8 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Settings/SettingDescriber.java @@ -0,0 +1,16 @@ +package net.Broken.Api.Data.Settings; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SettingDescriber( + String id, + String name, + String description, + TYPE type +) { + + public enum TYPE { + BOOL, LIST, STRING, ROLE, TEXT_CHANNEL, VOICE_CHANNEL + } +} diff --git a/src/main/java/net/Broken/Api/Data/Settings/SettingGroup.java b/src/main/java/net/Broken/Api/Data/Settings/SettingGroup.java new file mode 100644 index 0000000..a10180a --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Settings/SettingGroup.java @@ -0,0 +1,13 @@ +package net.Broken.Api.Data.Settings; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SettingGroup( + String name, + SettingDescriber mainField, + List fields +) { +} diff --git a/src/main/java/net/Broken/Api/Data/Settings/Value.java b/src/main/java/net/Broken/Api/Data/Settings/Value.java new file mode 100644 index 0000000..4050c54 --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/Settings/Value.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Data.Settings; + +public record Value(String id, Object value) { +} diff --git a/src/main/java/net/Broken/Api/Data/UserInfo.java b/src/main/java/net/Broken/Api/Data/UserInfo.java new file mode 100644 index 0000000..1de4cdf --- /dev/null +++ b/src/main/java/net/Broken/Api/Data/UserInfo.java @@ -0,0 +1,8 @@ +package net.Broken.Api.Data; + +public record UserInfo( + String id, + String username, + String avatar +) { +} diff --git a/src/main/java/net/Broken/Api/OpenApi/OpenApiConfig.java b/src/main/java/net/Broken/Api/OpenApi/OpenApiConfig.java new file mode 100644 index 0000000..681e437 --- /dev/null +++ b/src/main/java/net/Broken/Api/OpenApi/OpenApiConfig.java @@ -0,0 +1,35 @@ +package net.Broken.Api.OpenApi; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import net.Broken.VersionLoader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + private final VersionLoader versionLoader; + + public OpenApiConfig(VersionLoader version) { + this.versionLoader = version; + } + + @Bean + public OpenAPI customOpenAPI() { + final String securitySchemeName = "JWT"; + return new OpenAPI().addSecurityItem( + new SecurityRequirement().addList(securitySchemeName)).components( + new Components().addSecuritySchemes( + securitySchemeName, + new SecurityScheme().name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")) + ).addServersItem(new Server().url("/").description("Default")) + .info(new Info().title("ClaptrapBot API").version(versionLoader.getVersion())); + } +} \ No newline at end of file diff --git a/src/main/java/net/Broken/Api/Security/Components/DiscordAuthenticationProvider.java b/src/main/java/net/Broken/Api/Security/Components/DiscordAuthenticationProvider.java new file mode 100644 index 0000000..40b7bad --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Components/DiscordAuthenticationProvider.java @@ -0,0 +1,47 @@ +package net.Broken.Api.Security.Components; + +import net.Broken.Api.Security.Data.DiscordOauthUserInfo; +import net.Broken.Api.Security.Exceptions.OAuthLoginFail; +import net.Broken.Api.Security.Services.DiscordOauthService; +import net.Broken.DB.Entity.UserEntity; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; + +@Component +public class DiscordAuthenticationProvider implements AuthenticationProvider { + private final DiscordOauthService discordOauthService; + + public DiscordAuthenticationProvider(DiscordOauthService discordOauthService) { + this.discordOauthService = discordOauthService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String redirectUri = authentication.getPrincipal().toString(); + String code = authentication.getCredentials().toString(); + try { + String token = discordOauthService.getAccessToken(code, redirectUri); + DiscordOauthUserInfo discordOauthUserInfo = discordOauthService.getUserInfo(token); + discordOauthService.revokeToken(token); + DiscordOauthService.LoginOrRegisterResponse loginOrRegisterResponse = discordOauthService.loginOrRegisterDiscordUser(discordOauthUserInfo); + UserEntity userEntity = loginOrRegisterResponse.response(); + if(!loginOrRegisterResponse.created()){ + userEntity = discordOauthService.updateUserInfo(discordOauthUserInfo, loginOrRegisterResponse.response()); + } + return new UsernamePasswordAuthenticationToken(userEntity, null, new ArrayList<>()); + } catch (OAuthLoginFail e) { + throw new BadCredentialsException("Bad response form Discord Oauth server ! Code expired ?"); + } + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(UsernamePasswordAuthenticationToken.class); + } +} diff --git a/src/main/java/net/Broken/Api/Security/Components/UnauthorizedHandler.java b/src/main/java/net/Broken/Api/Security/Components/UnauthorizedHandler.java new file mode 100644 index 0000000..0cb9d77 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Components/UnauthorizedHandler.java @@ -0,0 +1,37 @@ +package net.Broken.Api.Security.Components; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Component +public class UnauthorizedHandler implements AuthenticationEntryPoint { + private final Logger logger = LogManager.getLogger(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + logger.error("[API] Unauthorized error: {}", authException.getMessage()); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + final Map body = new HashMap<>(); + body.put("status", HttpServletResponse.SC_UNAUTHORIZED); + body.put("error", "Unauthorized"); + body.put("message", authException.getMessage()); + body.put("path", request.getServletPath()); + + final ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), body); + } +} diff --git a/src/main/java/net/Broken/Api/Security/Data/AccessTokenResponse.java b/src/main/java/net/Broken/Api/Security/Data/AccessTokenResponse.java new file mode 100644 index 0000000..5512583 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Data/AccessTokenResponse.java @@ -0,0 +1,10 @@ +package net.Broken.Api.Security.Data; + +public record AccessTokenResponse( + String access_token, + String token_type, + String expires_in, + String refresh_token, + String scope +) { +} diff --git a/src/main/java/net/Broken/Api/Security/Data/DiscordOauthUserInfo.java b/src/main/java/net/Broken/Api/Security/Data/DiscordOauthUserInfo.java new file mode 100644 index 0000000..b1ffc0c --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Data/DiscordOauthUserInfo.java @@ -0,0 +1,12 @@ +package net.Broken.Api.Security.Data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DiscordOauthUserInfo( + String id, + String username, + String discriminator, + String avatar) { +} + diff --git a/src/main/java/net/Broken/Api/Security/Data/JwtPrincipal.java b/src/main/java/net/Broken/Api/Security/Data/JwtPrincipal.java new file mode 100644 index 0000000..763003f --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Data/JwtPrincipal.java @@ -0,0 +1,6 @@ +package net.Broken.Api.Security.Data; + +import net.Broken.DB.Entity.UserEntity; + +public record JwtPrincipal(String jwtId, UserEntity user) { +} diff --git a/src/main/java/net/Broken/Api/Security/Data/JwtResponse.java b/src/main/java/net/Broken/Api/Security/Data/JwtResponse.java new file mode 100644 index 0000000..8f93ab2 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Data/JwtResponse.java @@ -0,0 +1,5 @@ +package net.Broken.Api.Security.Data; + + +public record JwtResponse(String token) { +} diff --git a/src/main/java/net/Broken/Api/Security/Exceptions/OAuthLoginFail.java b/src/main/java/net/Broken/Api/Security/Exceptions/OAuthLoginFail.java new file mode 100644 index 0000000..8da9db2 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Exceptions/OAuthLoginFail.java @@ -0,0 +1,4 @@ +package net.Broken.Api.Security.Exceptions; + +public class OAuthLoginFail extends Exception{ +} diff --git a/src/main/java/net/Broken/Api/Security/Expression/CustomMethodSecurityExpressionHandler.java b/src/main/java/net/Broken/Api/Security/Expression/CustomMethodSecurityExpressionHandler.java new file mode 100644 index 0000000..87edfa8 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Expression/CustomMethodSecurityExpressionHandler.java @@ -0,0 +1,25 @@ +package net.Broken.Api.Security.Expression; + +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.core.Authentication; + +public class CustomMethodSecurityExpressionHandler + extends DefaultMethodSecurityExpressionHandler { + private final AuthenticationTrustResolver trustResolver = + new AuthenticationTrustResolverImpl(); + + @Override + protected MethodSecurityExpressionOperations createSecurityExpressionRoot( + Authentication authentication, MethodInvocation invocation) { + CustomMethodSecurityExpressionRoot root = + new CustomMethodSecurityExpressionRoot(authentication); + root.setPermissionEvaluator(getPermissionEvaluator()); + root.setTrustResolver(this.trustResolver); + root.setRoleHierarchy(getRoleHierarchy()); + return root; + } +} \ No newline at end of file diff --git a/src/main/java/net/Broken/Api/Security/Expression/CustomMethodSecurityExpressionRoot.java b/src/main/java/net/Broken/Api/Security/Expression/CustomMethodSecurityExpressionRoot.java new file mode 100644 index 0000000..2bacdf8 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Expression/CustomMethodSecurityExpressionRoot.java @@ -0,0 +1,102 @@ +package net.Broken.Api.Security.Expression; + +import net.Broken.Api.Data.Music.Connect; +import net.Broken.Api.Security.Data.JwtPrincipal; +import net.Broken.Audio.GuildAudioBotService; +import net.Broken.MainBot; +import net.Broken.Tools.CacheTools; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.VoiceChannel; +import org.springframework.security.access.expression.SecurityExpressionRoot; +import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations; +import org.springframework.security.core.Authentication; + +public class CustomMethodSecurityExpressionRoot + extends SecurityExpressionRoot + implements MethodSecurityExpressionOperations { + private Object filterObject; + private Object returnObject; + /** + * Creates a new instance + * + * @param authentication the {@link Authentication} to use. Cannot be null. + */ + public CustomMethodSecurityExpressionRoot(Authentication authentication) { + super(authentication); + } + + public boolean isInGuild(String guildId){ + JwtPrincipal jwtPrincipal = (JwtPrincipal) authentication.getPrincipal(); + Guild guild = MainBot.jda.getGuildById(guildId); + return CacheTools.getJdaUser(jwtPrincipal.user()).getMutualGuilds().contains(guild); + } + + public boolean canManageGuild(String guildId){ + JwtPrincipal jwtPrincipal = (JwtPrincipal) authentication.getPrincipal(); + Member member = MainBot.jda.getGuildById(guildId).getMemberById(jwtPrincipal.user().getDiscordId()); + return member.hasPermission( + Permission.MANAGE_SERVER, + Permission.MANAGE_PERMISSIONS, + Permission.MANAGE_CHANNEL + ); + } + + public boolean canInteractWithVoiceChannel(String guildId, Connect connectPayload){ + JwtPrincipal jwtPrincipal = (JwtPrincipal) authentication.getPrincipal(); + Guild guild = MainBot.jda.getGuildById(guildId); + Member member = guild.getMemberById(jwtPrincipal.user().getDiscordId()); + VoiceChannel channel = guild.getVoiceChannelById(connectPayload.channelId()); + if( channel == null){ + return false; + } + + return (member.hasPermission(channel, Permission.VOICE_CONNECT) + || member.getVoiceState() != null + && member.getVoiceState().getChannel() == channel) + && member.hasPermission(channel, Permission.VOICE_SPEAK); + } + + public boolean canInteractWithVoiceChannel(String guildId) { + JwtPrincipal jwtPrincipal = (JwtPrincipal) authentication.getPrincipal(); + Guild guild = MainBot.jda.getGuildById(guildId); + GuildAudioBotService guildAudioBotService = GuildAudioBotService.getInstance(guild); + VoiceChannel channel = guild.getAudioManager().getConnectedChannel(); + + if (channel == null) { + return false; + } + + Member member = guild.getMemberById(jwtPrincipal.user().getDiscordId()); + return (member.hasPermission(channel, Permission.VOICE_CONNECT) + || member.getVoiceState() != null + && member.getVoiceState().getChannel() == channel) + && member.hasPermission(channel, Permission.VOICE_SPEAK); + } + + @Override + public void setFilterObject(Object filterObject) { + this.filterObject = filterObject; + } + + @Override + public Object getFilterObject() { + return this.filterObject; + } + + @Override + public void setReturnObject(Object returnObject) { + this.returnObject = returnObject; + } + + @Override + public Object getReturnObject() { + return this.returnObject; + } + + @Override + public Object getThis() { + return this; + } +} diff --git a/src/main/java/net/Broken/Api/Security/Filters/JwtFilter.java b/src/main/java/net/Broken/Api/Security/Filters/JwtFilter.java new file mode 100644 index 0000000..771cadd --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Filters/JwtFilter.java @@ -0,0 +1,60 @@ +package net.Broken.Api.Security.Filters; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import net.Broken.Api.Security.Data.JwtPrincipal; +import net.Broken.Api.Security.Services.JwtService; +import net.Broken.BotConfigLoader; +import net.Broken.DB.Entity.UserEntity; +import net.Broken.DB.Repository.UserRepository; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; + +public class JwtFilter extends OncePerRequestFilter { + @Autowired + private JwtService jwtService; + @Autowired + private BotConfigLoader config; + @Autowired + private UserRepository userRepository; + private final Logger logger = LogManager.getLogger(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.replace("Bearer ", ""); + try { + UserEntity user; + JwtPrincipal principal; + if(config.mode().equals("DEV")){ + user = userRepository.findByDiscordId(token).orElseThrow(); + principal = new JwtPrincipal("DEV", user); + } + else { + Jws jwt = jwtService.verifyAndParseJwt(token); + user = jwtService.getUserWithJwt(jwt); + principal = new JwtPrincipal(jwt.getBody().getId(), user); + } + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(principal, null, new ArrayList<>()); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } catch (Exception e) { + logger.warn("[JWT] Cannot set user authentication: " + e); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/net/Broken/Api/Security/MethodSecurityConfig.java b/src/main/java/net/Broken/Api/Security/MethodSecurityConfig.java new file mode 100644 index 0000000..129bfc4 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/MethodSecurityConfig.java @@ -0,0 +1,16 @@ +package net.Broken.Api.Security; + +import net.Broken.Api.Security.Expression.CustomMethodSecurityExpressionHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration; + +@Configuration +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration { + @Override + protected MethodSecurityExpressionHandler createExpressionHandler() { + return new CustomMethodSecurityExpressionHandler(); + } +} diff --git a/src/main/java/net/Broken/Api/Security/SecurityConfig.java b/src/main/java/net/Broken/Api/Security/SecurityConfig.java new file mode 100644 index 0000000..7a6dfde --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/SecurityConfig.java @@ -0,0 +1,50 @@ +package net.Broken.Api.Security; + +import net.Broken.Api.Security.Components.UnauthorizedHandler; +import net.Broken.Api.Security.Filters.JwtFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@EnableWebSecurity +@Configuration +public class SecurityConfig extends WebSecurityConfigurerAdapter { + private final UnauthorizedHandler unauthorizedHandler; + public SecurityConfig(UnauthorizedHandler unauthorizedHandler) { + this.unauthorizedHandler = unauthorizedHandler; + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.cors().and().csrf().disable() + .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() + .authorizeRequests() + .antMatchers("/api/v2/auth/**").permitAll() + .antMatchers("/swagger-ui/**").permitAll() + .antMatchers("/swagger-ui.html").permitAll() + .antMatchers("/v3/api-docs/**").permitAll() + .antMatchers("/actuator/**").permitAll() + .anyRequest().authenticated(); + + http.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class); + + } + + @Bean + public JwtFilter jwtFilter(){ + return new JwtFilter(); + } + + + @Bean + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } +} diff --git a/src/main/java/net/Broken/Api/Security/Services/DiscordOauthService.java b/src/main/java/net/Broken/Api/Security/Services/DiscordOauthService.java new file mode 100644 index 0000000..5676833 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Services/DiscordOauthService.java @@ -0,0 +1,172 @@ +package net.Broken.Api.Security.Services; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.Broken.Api.Security.Data.AccessTokenResponse; +import net.Broken.Api.Security.Data.DiscordOauthUserInfo; +import net.Broken.Api.Security.Exceptions.OAuthLoginFail; +import net.Broken.DB.Entity.UserEntity; +import net.Broken.DB.Repository.UserRepository; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Service +public class DiscordOauthService { + + private final Logger logger = LogManager.getLogger(); + @Value("${discord.oauth.client-id}") + private String clientId; + + @Value("${discord.oauth.client-secret}") + private String clientSecret; + + @Value("${discord.oauth.token-endpoint}") + private String tokenEndpoint; + + @Value("${discord.oauth.tokenRevokeEndpoint}") + private String tokenRevokeEndpoint; + + @Value("${discord.oauth.userInfoEnpoint}") + private String userInfoEnpoint; + + private final UserRepository userRepository; + + public DiscordOauthService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public String getAccessToken(String code, String redirectUrl) throws OAuthLoginFail { + logger.debug("[OAUTH] Getting access token"); + HashMap data = new HashMap<>(); + data.put("client_id", this.clientId); + data.put("client_secret", this.clientSecret); + data.put("grant_type", "authorization_code"); + data.put("code", code); + data.put("redirect_uri", redirectUrl); + try { + HttpResponse response = makeFormPost(this.tokenEndpoint, data); + if (response.statusCode() != 200) { + logger.warn("[OAUTH] Invalid response while getting AccessToken: Status Code: " + response.statusCode() + " Body:" + response.body()); + throw new OAuthLoginFail(); + } + ObjectMapper objectMapper = new ObjectMapper(); + AccessTokenResponse accessTokenResponse = objectMapper.readValue(response.body(), AccessTokenResponse.class); + return accessTokenResponse.access_token(); + } catch (IOException | InterruptedException e) { + logger.catching(e); + throw new OAuthLoginFail(); + } + } + + public DiscordOauthUserInfo getUserInfo(String token) throws OAuthLoginFail { + logger.debug("[OAUTH] Getting user info"); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(this.userInfoEnpoint)) + .header("Authorization", "Bearer " + token) + .GET() + .build(); + HttpClient client = HttpClient.newHttpClient(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + logger.warn("[OAUTH] Invalid response while getting UserInfo: Status Code: " + response.statusCode() + " Body:" + response.body()); + throw new OAuthLoginFail(); + } + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(response.body(), DiscordOauthUserInfo.class); + } catch (IOException | InterruptedException e) { + logger.catching(e); + throw new OAuthLoginFail(); + } + } + + public void revokeToken(String token) { + logger.debug("[OAUTH] Revoking access token"); + HashMap data = new HashMap<>(); + data.put("token", token); + try { + HttpResponse response = makeFormPost(this.tokenRevokeEndpoint, data); + if (response.statusCode() != 200) { + logger.warn("[OAUTH] Invalid response while token revocation: Status Code: " + response.statusCode() + " Body:" + response.body()); + } + } catch (IOException | InterruptedException e) { + logger.catching(e); + } + } + + + public record LoginOrRegisterResponse(T response, boolean created) { + } + + public LoginOrRegisterResponse loginOrRegisterDiscordUser(DiscordOauthUserInfo discordOauthUserInfo) { + Optional optionalUserEntity = userRepository.findByDiscordId(discordOauthUserInfo.id()); + return optionalUserEntity.map( + userEntity -> new LoginOrRegisterResponse<>(userEntity, false)) + .orElseGet(() -> { + UserEntity created = userRepository.save(new UserEntity(discordOauthUserInfo)); + return new LoginOrRegisterResponse<>(created, true); + }); + } + + public UserEntity updateUserInfo(DiscordOauthUserInfo discordOauthUserInfo, UserEntity userEntity){ + boolean updated = false; + if(userEntity.getUsername() == null || !userEntity.getUsername().equals(discordOauthUserInfo.username())){ + userEntity.setUsername(discordOauthUserInfo.username()); + updated = true; + } + if(userEntity.getDiscriminator() == null || !userEntity.getDiscriminator().equals(discordOauthUserInfo.discriminator())){ + userEntity.setDiscriminator(discordOauthUserInfo.discriminator()); + updated = true; + } + if(userEntity.getAvatar() == null || !userEntity.getAvatar().equals(discordOauthUserInfo.avatar())){ + userEntity.setAvatar(discordOauthUserInfo.avatar()); + updated = true; + } + + if(updated){ + return userRepository.save(userEntity); + } + return userEntity; + } + + + private String getFormString(HashMap params) { + StringBuilder result = new StringBuilder(); + boolean first = true; + for (Map.Entry entry : params.entrySet()) { + if (first) + first = false; + else + result.append("&"); + result.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + result.append("="); + result.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); + } + return result.toString(); + } + + private HttpResponse makeFormPost(String endpoint, HashMap data) throws IOException, InterruptedException { + HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(getFormString(data)); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(body) + .build(); + HttpClient client = HttpClient.newHttpClient(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + +} diff --git a/src/main/java/net/Broken/Api/Security/Services/JwtService.java b/src/main/java/net/Broken/Api/Security/Services/JwtService.java new file mode 100644 index 0000000..1e4df47 --- /dev/null +++ b/src/main/java/net/Broken/Api/Security/Services/JwtService.java @@ -0,0 +1,69 @@ +package net.Broken.Api.Security.Services; + + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import net.Broken.DB.Entity.UserEntity; +import net.Broken.DB.Repository.UserRepository; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Calendar; +import java.util.Date; +import java.util.NoSuchElementException; +import java.util.UUID; + +@Service +public class JwtService { + + private final Key jwtKey; + + private final UserRepository userRepository; + + public JwtService(UserRepository userRepository) { + this.userRepository = userRepository; + this.jwtKey = Keys.secretKeyFor(SignatureAlgorithm.HS256); + } + + public String buildJwt(UserEntity user){ + Date iat = new Date(); + Date nbf = new Date(); + Calendar expCal = Calendar.getInstance(); + expCal.add(Calendar.DATE, 7); + Date exp = expCal.getTime(); + UUID uuid = UUID.randomUUID(); + + + return Jwts.builder() + .setSubject(user.getUsername()) + .claim("discord_id", user.getDiscordId()) + .claim("avatar", user.getAvatar()) + .claim("discriminator", user.getDiscriminator()) + .setId(uuid.toString()) + .setIssuedAt(iat) + .setNotBefore(nbf) + .setExpiration(exp) + .signWith(this.jwtKey) + .compact(); + + + } + + + public Jws verifyAndParseJwt(String token) { + return Jwts.parserBuilder() + .setSigningKey(this.jwtKey) + .build() + .parseClaimsJws(token); + } + + + public UserEntity getUserWithJwt(Jws jwt) throws NoSuchElementException { + String discordId = jwt.getBody().get("discord_id", String.class); + return userRepository.findByDiscordId(discordId) + .orElseThrow(); + } +} diff --git a/src/main/java/net/Broken/Api/Services/AudioService.java b/src/main/java/net/Broken/Api/Services/AudioService.java new file mode 100644 index 0000000..e3a5708 --- /dev/null +++ b/src/main/java/net/Broken/Api/Services/AudioService.java @@ -0,0 +1,131 @@ +package net.Broken.Api.Services; + +import net.Broken.Api.Data.Guild.Channel; +import net.Broken.Api.Data.Music.*; +import net.Broken.Audio.GuildAudioBotService; +import net.Broken.Audio.UserAudioTrack; +import net.Broken.MainBot; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.audio.hooks.ConnectionStatus; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.VoiceChannel; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.util.concurrent.ExecutionException; + +@Service +public class AudioService { + + final Logger logger = LogManager.getLogger(); + + public Status getGuildAudioStatus(String guildId, String userId) { + Guild guild = MainBot.jda.getGuildById(guildId); + Member member = guild.getMemberById(userId); + + + VoiceChannel channel = guild.getAudioManager().getConnectedChannel(); + ConnectionStatus status = guild.getAudioManager().getConnectionStatus(); + if (channel != null) { +// The user can view the audio status if: +// -> He can view the voice channel +// -> OR He can *not* view the voice channel, but he is connected to this voice channel + boolean canView = member.hasPermission(channel, Permission.VIEW_CHANNEL) + || (member.getVoiceState() != null + && member.getVoiceState().getChannel() == channel); + GuildAudioBotService guildAudioBotService = GuildAudioBotService.getInstance(guild); + + if (canView) { +// The user can interact with the audio if: +// -> He can connect to this voice channel +// -> OR he is connected to this voice channel +// -> AND He can speak in this voice channel + boolean canInteract = (member.hasPermission(channel, Permission.VOICE_CONNECT) + || member.getVoiceState() != null + && member.getVoiceState().getChannel() == channel) + && member.hasPermission(channel, Permission.VOICE_SPEAK); + + + boolean stopped = guildAudioBotService.getGuidAudioManager().player.getPlayingTrack() == null; + PlayBackInfo playBackInfo; + if (!stopped) { + boolean paused = guildAudioBotService.getGuidAudioManager().player.isPaused(); + long position = guildAudioBotService.getGuidAudioManager().player.getPlayingTrack().getPosition(); + UserAudioTrack userAudioTrack = guildAudioBotService.getGuidAudioManager().scheduler.getCurrentPlayingTrack(); + + playBackInfo = new PlayBackInfo(paused, false, position, new TrackInfo(userAudioTrack)); + + } else { + playBackInfo = new PlayBackInfo(false, true, null, null); + } + Channel channelApi = new Channel(channel.getId(), channel.getName()); + return new Status(true, status, channelApi, true, canInteract, playBackInfo); + + } else { + return new Status(true, status, null, false, false, null); + } + } + return new Status(false, status, null, null, null, null); + } + + public ResponseEntity connect(String guildId, Connect body, String userId) { + Guild guild = MainBot.jda.getGuildById(guildId); + VoiceChannel voiceChannel = guild.getVoiceChannelById(body.channelId()); + GuildAudioBotService.getInstance(guild).connect(voiceChannel); + Status status = getGuildAudioStatus(guildId, userId); + return new ResponseEntity<>(status, HttpStatus.OK); + } + + public ResponseEntity disconnect(String guildId, String userId) { + Guild guild = MainBot.jda.getGuildById(guildId); + GuildAudioBotService guildAudioBotService = GuildAudioBotService.getInstance(guild); + guildAudioBotService.disconnect(); + Status status = getGuildAudioStatus(guildId, userId); + return new ResponseEntity<>(status, HttpStatus.OK); + } + + + public ResponseEntity pause(String guildId, String userId) { + Guild guild = MainBot.jda.getGuildById(guildId); + GuildAudioBotService.getInstance(guild).pause(); + Status status = getGuildAudioStatus(guildId, userId); + return new ResponseEntity<>(status, HttpStatus.OK); + } + + public ResponseEntity resume(String guildId, String userId) { + Guild guild = MainBot.jda.getGuildById(guildId); + GuildAudioBotService.getInstance(guild).resume(); + Status status = getGuildAudioStatus(guildId, userId); + return new ResponseEntity<>(status, HttpStatus.OK); + } + + public ResponseEntity skip(String guildId, String userId) { + Guild guild = MainBot.jda.getGuildById(guildId); + GuildAudioBotService.getInstance(guild).skipTrack(); + Status status = getGuildAudioStatus(guildId, userId); + return new ResponseEntity<>(status, HttpStatus.OK); + } + + public ResponseEntity stop(String guildId, String userId) { + Guild guild = MainBot.jda.getGuildById(guildId); + GuildAudioBotService.getInstance(guild).stop(); + Status status = getGuildAudioStatus(guildId, userId); + return new ResponseEntity<>(status, HttpStatus.OK); + } + + public ResponseEntity add(String guildId, String userId, Add body) throws ExecutionException, InterruptedException { + Guild guild = MainBot.jda.getGuildById(guildId); + boolean success = GuildAudioBotService.getInstance(guild).loadAndPlaySync(body.url(), userId); + if (success) { + Status status = getGuildAudioStatus(guildId, userId); + return new ResponseEntity<>(status, HttpStatus.OK); + } else { + return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); + } + + } +} diff --git a/src/main/java/net/Broken/Api/Services/GuildService.java b/src/main/java/net/Broken/Api/Services/GuildService.java new file mode 100644 index 0000000..867c086 --- /dev/null +++ b/src/main/java/net/Broken/Api/Services/GuildService.java @@ -0,0 +1,74 @@ +package net.Broken.Api.Services; + +import net.Broken.Api.Data.Guild.Channel; +import net.Broken.Api.Data.Guild.Guild; +import net.Broken.Api.Data.Guild.Role; +import net.Broken.DB.Entity.UserEntity; +import net.Broken.MainBot; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class GuildService { + public List getMutualGuilds(UserEntity user) { + User discordUser = MainBot.jda.retrieveUserById(user.getDiscordId()).complete(); + List mutualGuilds = discordUser.getMutualGuilds(); + List guildList = new ArrayList<>(); + + for (net.dv8tion.jda.api.entities.Guild guild : mutualGuilds) { + boolean canManage = guild.getMember(discordUser).hasPermission( + Permission.MANAGE_SERVER, + Permission.MANAGE_PERMISSIONS, + Permission.MANAGE_CHANNEL + ); + guildList.add(new Guild(guild.getId(), guild.getName(), guild.getIconUrl(), canManage)); + } + return guildList; + } + + public List getVoiceChannel(String guildId, String userId) { + net.dv8tion.jda.api.entities.Guild guild = MainBot.jda.getGuildById(guildId); + Member member = guild.getMemberById(userId); + List voiceChannels = new ArrayList<>(); + for (net.dv8tion.jda.api.entities.VoiceChannel voiceChannel : guild.getVoiceChannels()) { + if (member.hasPermission(voiceChannel, Permission.VIEW_CHANNEL)) { + voiceChannels.add(new Channel(voiceChannel.getId(), voiceChannel.getName())); + } + } + return voiceChannels; + } + + public List getTextChannel(String guildId, String userId) { + net.dv8tion.jda.api.entities.Guild guild = MainBot.jda.getGuildById(guildId); + Member member = guild.getMemberById(userId); + List voiceChannels = new ArrayList<>(); + for (net.dv8tion.jda.api.entities.TextChannel textChannel : guild.getTextChannels()) { + if (member.hasPermission(textChannel, Permission.VIEW_CHANNEL)) { + voiceChannels.add(new Channel(textChannel.getId(), textChannel.getName())); + } + } + return voiceChannels; + } + + public List getRole(String guildId) { + net.dv8tion.jda.api.entities.Guild guild = MainBot.jda.getGuildById(guildId); + List roles = new ArrayList<>(); + for (net.dv8tion.jda.api.entities.Role role : guild.getRoles()) { + if (!role.isManaged()) { + roles.add(new Role(role.getId(), role.getName())); + } + } + return roles; + } + + + public String getInviteLink(){ + return MainBot.jda.setRequiredScopes("applications.commands").getInviteUrl(Permission.getPermissions(1644971949399L)); + } + +} diff --git a/src/main/java/net/Broken/Api/Services/SettingService.java b/src/main/java/net/Broken/Api/Services/SettingService.java new file mode 100644 index 0000000..827597e --- /dev/null +++ b/src/main/java/net/Broken/Api/Services/SettingService.java @@ -0,0 +1,48 @@ +package net.Broken.Api.Services; + +import net.Broken.Api.Data.Settings.SettingGroup; +import net.Broken.Api.Data.Settings.Value; +import net.Broken.DB.Entity.GuildPreferenceEntity; +import net.Broken.DB.Repository.GuildPreferenceRepository; +import net.Broken.Tools.Settings.SettingDescriptionBuilder; +import net.Broken.Tools.Settings.SettingSaver; +import net.Broken.Tools.Settings.SettingValueBuilder; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class SettingService { + + public final GuildPreferenceRepository preferenceRepository; + private final Logger logger = LogManager.getLogger(); + + + public SettingService(GuildPreferenceRepository preferenceRepository) { + this.preferenceRepository = preferenceRepository; + } + + public List getSettingDescription() { + return new SettingDescriptionBuilder().build(); + } + + + public List getValues(String guildId) { + GuildPreferenceEntity pref = preferenceRepository.findByGuildId(guildId).orElseGet(() -> { + logger.info("[API] : Generate default guild pref"); + return preferenceRepository.save(GuildPreferenceEntity.getDefault(guildId)); + }); + return new SettingValueBuilder(pref).build(); + } + + public GuildPreferenceEntity saveValue(String guildId, List values){ + GuildPreferenceEntity pref = preferenceRepository.findByGuildId(guildId).orElseGet(() -> { + logger.info("[API] : Generate default guild pref"); + return preferenceRepository.save(GuildPreferenceEntity.getDefault(guildId)); + }); + return new SettingSaver(preferenceRepository, pref).save(values); + } + +} diff --git a/src/main/java/net/Broken/audio/AudioPlayerSendHandler.java b/src/main/java/net/Broken/Audio/AudioPlayerSendHandler.java similarity index 94% rename from src/main/java/net/Broken/audio/AudioPlayerSendHandler.java rename to src/main/java/net/Broken/Audio/AudioPlayerSendHandler.java index 6a3f600..665d3aa 100644 --- a/src/main/java/net/Broken/audio/AudioPlayerSendHandler.java +++ b/src/main/java/net/Broken/Audio/AudioPlayerSendHandler.java @@ -1,4 +1,4 @@ -package net.Broken.audio; +package net.Broken.Audio; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.track.playback.MutableAudioFrame; @@ -33,7 +33,7 @@ public class AudioPlayerSendHandler implements AudioSendHandler { @Override public ByteBuffer provide20MsAudio() { - return (ByteBuffer) buffer.flip(); + return buffer.flip(); } @Override diff --git a/src/main/java/net/Broken/audio/AudioM.java b/src/main/java/net/Broken/Audio/GuildAudioBotService.java similarity index 68% rename from src/main/java/net/Broken/audio/AudioM.java rename to src/main/java/net/Broken/Audio/GuildAudioBotService.java index 28f7a79..2c4bde2 100644 --- a/src/main/java/net/Broken/audio/AudioM.java +++ b/src/main/java/net/Broken/Audio/GuildAudioBotService.java @@ -1,4 +1,4 @@ -package net.Broken.audio; +package net.Broken.Audio; import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; @@ -9,7 +9,6 @@ import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; import net.Broken.MainBot; -import net.Broken.RestApi.Data.UserAudioTrackData; import net.Broken.Tools.EmbedMessageUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.MessageBuilder; @@ -18,76 +17,61 @@ import net.dv8tion.jda.api.events.interaction.GenericInteractionCreateEvent; import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; import net.dv8tion.jda.api.interactions.components.ActionRow; import net.dv8tion.jda.api.interactions.components.Button; -import net.dv8tion.jda.api.interactions.components.ComponentLayout; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.awt.*; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; +import java.util.*; import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; -public class AudioM { +public class GuildAudioBotService { + + private static final HashMap INSTANCES = new HashMap<>(); + + private final GuildAudioManager guildAudioManager; + + private final AudioPlayerManager audioPlayerManager; - private static HashMap INSTANCES = new HashMap<>(); - /** - * Music manager for this guild - */ - private GuildMusicManager musicManager; - /** - * Audio player manager for this guild - */ - private AudioPlayerManager playerManager; - /** - * Current voice chanel (null if not connected) - */ - private VoiceChannel playedChanel; - /** - * Time out for list message - */ - private int listTimeOut = 30; /** * Extrem limit for playlist */ - private int listExtremLimit = 300; - /** - * Current guild - */ - private Guild guild; - private Logger logger = LogManager.getLogger(); + private final int listExtremLimit = 300; + + private final Guild guild; + private final Logger logger = LogManager.getLogger(); + + private final Map addStatus = new HashMap<>(); private Message lastMessageWithButton; - private AudioM(Guild guild) { - this.playerManager = new DefaultAudioPlayerManager(); - AudioSourceManagers.registerRemoteSources(playerManager); - AudioSourceManagers.registerLocalSource(playerManager); + private GuildAudioBotService(Guild guild) { + this.audioPlayerManager = new DefaultAudioPlayerManager(); + AudioSourceManagers.registerRemoteSources(audioPlayerManager); + AudioSourceManagers.registerLocalSource(audioPlayerManager); + this.guildAudioManager = new GuildAudioManager(audioPlayerManager, guild); + guild.getAudioManager().setSendingHandler(guildAudioManager.getSendHandler()); this.guild = guild; } - public static AudioM getInstance(Guild guild) { + public static GuildAudioBotService getInstance(Guild guild) { if (!INSTANCES.containsKey(guild)) { - INSTANCES.put(guild, new AudioM(guild)); + INSTANCES.put(guild, new GuildAudioBotService(guild)); } - return INSTANCES.get(guild); } /** * Load audio track from url, connect to chanel if not connected * - * @param event * @param voiceChannel Voice channel to connect if no connected * @param trackUrl Audio track url * @param playlistLimit Limit of playlist * @param onHead True for adding audio track on top of playlist */ public void loadAndPlay(SlashCommandEvent event, VoiceChannel voiceChannel, final String trackUrl, int playlistLimit, boolean onHead) { - GuildMusicManager musicManager = getGuildAudioPlayer(); - playedChanel = voiceChannel; - - playerManager.loadItemOrdered(musicManager, trackUrl, new AudioLoadResultHandler() { + audioPlayerManager.loadItemOrdered(guildAudioManager, trackUrl, new AudioLoadResultHandler() { @Override public void trackLoaded(AudioTrack track) { logger.info("[" + guild + "] Single Track detected!"); @@ -97,7 +81,7 @@ public class AudioM { .build(); clearLastButton(); lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); - play(guild, voiceChannel, musicManager, uat, onHead); + play(guild, voiceChannel, guildAudioManager, uat, onHead); } @Override @@ -130,34 +114,43 @@ public class AudioM { }); } - public void loadAndPlayAuto(String trackUrl) { - playerManager.loadItemOrdered(musicManager, trackUrl, new AudioLoadResultHandler() { + public boolean loadAndPlaySync(String trackUrl, String userId) throws ExecutionException, InterruptedException { + Member member = guild.getMemberById(userId); + VoiceChannel playedChanel = guild.getAudioManager().getConnectedChannel(); + final String uuid = UUID.randomUUID().toString(); + Future future = audioPlayerManager.loadItemOrdered(guildAudioManager, trackUrl, new AudioLoadResultHandler() { @Override public void trackLoaded(AudioTrack track) { logger.info("[" + guild + "] Auto add " + track.getInfo().title + " to playlist."); - UserAudioTrack userAudioTrack = new UserAudioTrack(MainBot.jda.getSelfUser(), track); - play(guild, playedChanel, musicManager, userAudioTrack, true); + UserAudioTrack userAudioTrack = new UserAudioTrack(member.getUser(), track); + play(guild, playedChanel, guildAudioManager, userAudioTrack, true); + addStatus.put(uuid, true); } @Override public void playlistLoaded(AudioPlaylist playlist) { AudioTrack track = playlist.getTracks().get(0); logger.info("[" + guild + "] Auto add " + track.getInfo().title + " to playlist."); - UserAudioTrack userAudioTrack = new UserAudioTrack(MainBot.jda.getSelfUser(), track); - play(guild, playedChanel, musicManager, userAudioTrack, true); + UserAudioTrack userAudioTrack = new UserAudioTrack(member.getUser(), track); + play(guild, playedChanel, guildAudioManager, userAudioTrack, true); + addStatus.put(uuid, true); } @Override public void noMatches() { logger.warn("[" + guild + "] Track not found: " + trackUrl); + addStatus.put(uuid, false); } @Override public void loadFailed(FriendlyException exception) { logger.error("[" + guild + "] Cant load media!"); logger.error(exception.getMessage()); + addStatus.put(uuid, false); } }); + future.get(); + return addStatus.remove(uuid); } @@ -170,31 +163,23 @@ public class AudioM { * @param onHead True for adding audio track on top of playlist */ public void playListLoader(AudioPlaylist playlist, int playlistLimit, User user, boolean onHead) { - int i = 0; + + VoiceChannel playedChanel = guild.getAudioManager().getConnectedChannel(); List tracks = playlist.getTracks(); if (onHead) Collections.reverse(tracks); + int i = 0; for (AudioTrack track : playlist.getTracks()) { UserAudioTrack uat = new UserAudioTrack(user, track); - play(guild, playedChanel, musicManager, uat, onHead); - i++; - if ((i >= playlistLimit && i != -1) || i > listExtremLimit) + play(guild, playedChanel, guildAudioManager, uat, onHead); + if ((playlistLimit != -1 && i >= playlistLimit) || i > listExtremLimit) break; + i++; } } - public GuildMusicManager getGuildAudioPlayer() { - if (musicManager == null) { - musicManager = new GuildMusicManager(playerManager, guild); - } - - guild.getAudioManager().setSendingHandler(musicManager.getSendHandler()); - - return musicManager; - } - /** * Add single track to playlist, auto-connect if not connected to vocal chanel * @@ -204,7 +189,7 @@ public class AudioM { * @param track Track to add to playlist * @param onHead True for adding audio track on top of playlist */ - public void play(Guild guild, VoiceChannel channel, GuildMusicManager musicManager, UserAudioTrack track, boolean onHead) { + public void play(Guild guild, VoiceChannel channel, GuildAudioManager musicManager, UserAudioTrack track, boolean onHead) { if (!guild.getAudioManager().isConnected()) guild.getAudioManager().openAudioConnection(channel); if (!onHead) @@ -213,32 +198,21 @@ public class AudioM { musicManager.scheduler.addNext(track); } - /** - * Skip current track - * - * @param event - */ - public void skipTrack(GenericInteractionCreateEvent event) { - GuildMusicManager musicManager = getGuildAudioPlayer(); - musicManager.scheduler.nextTrack(); - Message message = new MessageBuilder().setEmbeds( - EmbedMessageUtils.buildStandar( - new EmbedBuilder() - .setTitle(":track_next: Next Track") - .setColor(Color.green) - )).build(); - clearLastButton(); - lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); + public void add(SlashCommandEvent event, String url, int playListLimit, boolean onHead) { + if (guild.getAudioManager().isConnected()) { + loadAndPlay(event, guild.getAudioManager().getConnectedChannel(), url, playListLimit, onHead); + } else { + Message message = new MessageBuilder().setEmbeds(EmbedMessageUtils.getMusicError("Not connected to vocal chanel !")).build(); + event.getHook().setEphemeral(true).sendMessage(message).queue(); + } + } + + public void connect(VoiceChannel voiceChannel) { + guild.getAudioManager().openAudioConnection(voiceChannel); } - /** - * Pause current track - * - * @param event - */ public void pause(GenericInteractionCreateEvent event) { - GuildMusicManager musicManager = getGuildAudioPlayer(); - musicManager.scheduler.pause(); + pause(); Message message = new MessageBuilder().setEmbeds( EmbedMessageUtils.buildStandar( new EmbedBuilder() @@ -247,27 +221,23 @@ public class AudioM { )).build(); clearLastButton(); lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); - - } - /** - * Resume paused track - * - * @param event - */ + public void pause() { + guildAudioManager.scheduler.pause(); + } + public void resume(GenericInteractionCreateEvent event) { - GuildMusicManager musicManager = getGuildAudioPlayer(); Message message; - if(musicManager.player.getPlayingTrack() == null){ + if (guildAudioManager.player.getPlayingTrack() == null) { message = new MessageBuilder().setEmbeds( EmbedMessageUtils.buildStandar( new EmbedBuilder() .setTitle(":warning: Nothing to play, playlist is empty !") .setColor(Color.green) )).build(); - }else{ - musicManager.scheduler.resume(); + } else { + resume(); message = new MessageBuilder().setEmbeds( EmbedMessageUtils.buildStandar( new EmbedBuilder() @@ -279,23 +249,73 @@ public class AudioM { lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); } - /** - * Print current played track info - * - * @param event - */ + public void resume() { + guildAudioManager.scheduler.resume(); + } + + public void skipTrack(GenericInteractionCreateEvent event) { + skipTrack(); + Message message = new MessageBuilder().setEmbeds( + EmbedMessageUtils.buildStandar( + new EmbedBuilder() + .setTitle(":track_next: Next Track") + .setColor(Color.green) + )).build(); + clearLastButton(); + lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); + } + + public void skipTrack() { + guildAudioManager.scheduler.nextTrack(); + } + + public void stop(GenericInteractionCreateEvent event) { + stop(); + Message message = new MessageBuilder().setEmbeds( + EmbedMessageUtils.buildStandar( + new EmbedBuilder() + .setTitle(":stop_button: Playback stopped") + .setColor(Color.green) + )).build(); + clearLastButton(); + lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); + + } + + public void stop() { + guildAudioManager.scheduler.stop(); + guildAudioManager.scheduler.flush(); + clearLastButton(); + } + + public void disconnect(GenericInteractionCreateEvent event) { + disconnect(); + Message message = new MessageBuilder().setEmbeds( + EmbedMessageUtils.buildStandar( + new EmbedBuilder() + .setTitle(":eject: Disconnected") + .setColor(Color.green) + )).build(); + clearLastButton(); + event.getHook().sendMessage(message).queue(); + } + + public void disconnect() { + guildAudioManager.scheduler.stop(); + guildAudioManager.scheduler.flush(); + guild.getAudioManager().closeAudioConnection(); + clearLastButton(); + } public void info(GenericInteractionCreateEvent event) { - GuildMusicManager musicManager = getGuildAudioPlayer(); - AudioTrackInfo info = musicManager.scheduler.getInfo(); - UserAudioTrack userAudioTrack = musicManager.scheduler.getCurrentPlayingTrack(); + AudioTrackInfo info = guildAudioManager.scheduler.getInfo(); + UserAudioTrack userAudioTrack = guildAudioManager.scheduler.getCurrentPlayingTrack(); Message message = new MessageBuilder().setEmbeds(EmbedMessageUtils.getMusicInfo(info, userAudioTrack)).build(); clearLastButton(); lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); } public void flush(GenericInteractionCreateEvent event) { - GuildMusicManager musicManager = getGuildAudioPlayer(); - musicManager.scheduler.flush(); + guildAudioManager.scheduler.flush(); Message message = new MessageBuilder().setEmbeds( EmbedMessageUtils.buildStandar( new EmbedBuilder() @@ -312,8 +332,7 @@ public class AudioM { * @param event */ public void list(GenericInteractionCreateEvent event) { - GuildMusicManager musicManager = getGuildAudioPlayer(); - List list = musicManager.scheduler.getList(); + List list = guildAudioManager.scheduler.getList(); if (list.size() == 0) { Message message = new MessageBuilder().setEmbeds( @@ -327,11 +346,11 @@ public class AudioM { } else { StringBuilder resp = new StringBuilder(); int i = 0; - for (UserAudioTrackData trackInfo : list) { + for (UserAudioTrack trackInfo : list) { resp.append(":arrow_right: "); - resp.append(trackInfo.getAudioTrackInfo().title); + resp.append(trackInfo.getAudioTrack().getInfo().title); resp.append(" - "); - resp.append(trackInfo.getAudioTrackInfo().author); + resp.append(trackInfo.getAudioTrack().getInfo().author); resp.append("\n\n"); if (i >= 5) { resp.append(":arrow_forward: And "); @@ -353,123 +372,41 @@ public class AudioM { } - /** - * Called by //add, only if already connected - * - * @param event - * @param url Audio track url - * @param playListLimit Limit of playlist - * @param onHead True for adding audio track on top of playlist - */ - public void add(SlashCommandEvent event, String url, int playListLimit, boolean onHead) { - if (playedChanel != null) { - loadAndPlay(event, playedChanel, url, playListLimit, onHead); - } else { - Message message = new MessageBuilder().setEmbeds(EmbedMessageUtils.getMusicError("Not connected to vocal chanel !")).build(); - event.getHook().setEphemeral(true).sendMessage(message).queue(); - } - } - - /** - * Stop current playing track and flush playlist - * - * @param event - */ - public void stop(GenericInteractionCreateEvent event) { - musicManager.scheduler.stop(); - musicManager.scheduler.flush(); - - if (event != null) { - Message message = new MessageBuilder().setEmbeds( - EmbedMessageUtils.buildStandar( - new EmbedBuilder() - .setTitle(":stop_button: Playback stopped") - .setColor(Color.green) - )).build(); - clearLastButton(); - lastMessageWithButton = event.getHook().sendMessage(message).addActionRow(getActionButton()).complete(); - } - } - - public void disconect(GenericInteractionCreateEvent event){ - GuildMusicManager musicManager = getGuildAudioPlayer(); - musicManager.scheduler.stop(); - musicManager.scheduler.flush(); - playedChanel = null; - guild.getAudioManager().closeAudioConnection(); - clearLastButton(); - Message message = new MessageBuilder().setEmbeds( - EmbedMessageUtils.buildStandar( - new EmbedBuilder() - .setTitle(":eject: Disconnected") - .setColor(Color.green) - )).build(); - clearLastButton(); - event.getHook().sendMessage(message).queue(); - } - - /** - * Stop current playing track and flush playlist (no confirmation message) - */ - public void stop() { - - GuildMusicManager musicManager = getGuildAudioPlayer(); - musicManager.scheduler.stop(); - musicManager.scheduler.flush(); - playedChanel = null; - guild.getAudioManager().closeAudioConnection(); - clearLastButton(); - } - - public GuildMusicManager getGuildMusicManager() { - if (musicManager == null) - musicManager = getGuildAudioPlayer(); - return musicManager; - - } - public Guild getGuild() { return guild; } - public AudioPlayerManager getPlayerManager() { - return playerManager; + public GuildAudioManager getGuidAudioManager() { + return guildAudioManager; } - public VoiceChannel getPlayedChanel() { - return playedChanel; - } - - public void setPlayedChanel(VoiceChannel playedChanel) { - this.playedChanel = playedChanel; - } public void clearLastButton() { - if (lastMessageWithButton != null){ + if (lastMessageWithButton != null) { this.lastMessageWithButton.editMessageComponents(new ArrayList<>()).queue(); this.lastMessageWithButton = null; } } - public void updateLastButton(){ + + public void updateLastButton() { if (lastMessageWithButton != null) lastMessageWithButton = lastMessageWithButton.editMessageComponents(ActionRow.of(getActionButton())).complete(); } - private List