From 9b00ab0bae85b6e372f0c436e2b74dcb2086f5f8 Mon Sep 17 00:00:00 2001 From: Alix von Schirp Date: Sun, 12 Mar 2023 17:53:52 +0100 Subject: [PATCH] Base project structure and files --- .gitignore | 6 ++ pom.xml | 98 +++++++++++++++++++ .../b00tload/bsu/dedupe/SpotifyDedupe.java | 32 ++++++ .../dedupe/exceptions/ConfigException.java | 21 ++++ .../bsu/dedupe/util/BrowserHelper.java | 42 ++++++++ .../bsu/dedupe/util/CryptoHelper.java | 80 +++++++++++++++ .../bsu/dedupe/util/SpotifyCredentials.java | 70 +++++++++++++ .../b00tload/bsu/dedupe/util/TokenHelper.java | 31 ++++++ .../bsu/dedupe/util/TomlConfiguration.java | 82 ++++++++++++++++ .../bsu/dedupe/util/VersionChecker.java | 45 +++++++++ src/main/resources/config/default.toml | 16 +++ 11 files changed, 523 insertions(+) create mode 100644 pom.xml create mode 100644 src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java create mode 100644 src/main/java/space/b00tload/bsu/dedupe/exceptions/ConfigException.java create mode 100644 src/main/java/space/b00tload/bsu/dedupe/util/BrowserHelper.java create mode 100644 src/main/java/space/b00tload/bsu/dedupe/util/CryptoHelper.java create mode 100644 src/main/java/space/b00tload/bsu/dedupe/util/SpotifyCredentials.java create mode 100644 src/main/java/space/b00tload/bsu/dedupe/util/TokenHelper.java create mode 100644 src/main/java/space/b00tload/bsu/dedupe/util/TomlConfiguration.java create mode 100644 src/main/java/space/b00tload/bsu/dedupe/util/VersionChecker.java create mode 100644 src/main/resources/config/default.toml diff --git a/.gitignore b/.gitignore index a1c2a23..bd657d9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* + +#JetBrains IntelliJ folder +/.idea/ + +#Maven Generated +/target/ \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0e7e70c --- /dev/null +++ b/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + space.b00tload + SpotifyDedupe + 1.0.0.alpha1 + + SpotifyDeduper + Removes duplicates from spotify playlists. + 2023 + https://github.com/B00tLoad/SpotifyDedupe + + + + GNU General Public License v3.0 + https://github.com/B00tLoad/SpotifyDedupe/blob/master/LICENSE.md + + + + + + B00tLoad_ + Alix von Schirp + alix.von-schirp@bootmedia.de + + developer + + Europe/Berlin + + Alix | B00tLoad_#9370 + she/they + + + + + + GitHub + https://github.com/B00tLoad/SpotifyDedupe/issues + + + + 18 + 18 + UTF-8 + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + package + + single + + + + + + jar-with-dependencies + + + + space.b00tload.bsu.dedupe.SpotifyDedupe + true + true + + + + + + + + + + se.michaelthelin.spotify + spotify-web-api-java + 7.3.0 + + + commons-io + commons-io + 2.11.0 + + + com.electronwill.night-config + toml + 3.6.0 + + + + \ No newline at end of file diff --git a/src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java b/src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java new file mode 100644 index 0000000..7db5a74 --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/SpotifyDedupe.java @@ -0,0 +1,32 @@ +package space.b00tload.bsu.dedupe; + +import space.b00tload.bsu.dedupe.util.TomlConfiguration; +import space.b00tload.bsu.dedupe.util.VersionChecker; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +public class SpotifyDedupe { + + public static final String LINE_SEPERATOR = System.getProperty("line.separator"); + public static final String USER_HOME = System.getProperty("user.home"); + public static final String CONFIG_BASE = Paths.get(USER_HOME, ".bsu", "dedupe").toString(); + public static final Path CREDENTIAL_LOCATION = Paths.get(CONFIG_BASE, "spotify.bsucred"); + + private static List requiredConfig = List.of("spotify.client", "spotify.secret"); + + + public static void main(String[] args) { + Runtime.getRuntime().addShutdownHook(new Thread(TomlConfiguration::close)); + + VersionChecker.checkVerion(); + + if(!Paths.get(CONFIG_BASE).toFile().exists()) Paths.get(CONFIG_BASE).toFile().mkdirs(); + + TomlConfiguration.validate(requiredConfig); + + + } + +} diff --git a/src/main/java/space/b00tload/bsu/dedupe/exceptions/ConfigException.java b/src/main/java/space/b00tload/bsu/dedupe/exceptions/ConfigException.java new file mode 100644 index 0000000..7efa93b --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/exceptions/ConfigException.java @@ -0,0 +1,21 @@ +package space.b00tload.bsu.dedupe.exceptions; + + +public class ConfigException extends RuntimeException{ + + public ConfigException(String message){ + super(message); + } + + public ConfigException(String message, Throwable cause){ + super(message, cause); + } + + public ConfigException(Throwable cause){ + super(cause); + } + + public ConfigException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace){ + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/BrowserHelper.java b/src/main/java/space/b00tload/bsu/dedupe/util/BrowserHelper.java new file mode 100644 index 0000000..d8877e1 --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/util/BrowserHelper.java @@ -0,0 +1,42 @@ +package space.b00tload.bsu.dedupe.util; + +public class BrowserHelper { + + /** + * Opens a url in the systems default browser + * @param url + */ + public static void openInBrowser(String url) { + + String os = System.getProperty("os.name").toLowerCase(); + ProcessBuilder builder; + + if (os.indexOf("win") >= 0) { + // Windows + builder = new ProcessBuilder("rundll32.exe","url.dll,FileProtocolHandler", url); + } else if (os.indexOf("mac") >= 0) { + // Mac + builder = new ProcessBuilder("open", url); + } else if (os.indexOf("nix") >= 0 || os.indexOf("nux") >= 0) { + // Linux + os = "linux"; + builder = new ProcessBuilder("xdg-open", url); + } else { + System.out.println("Please open the following link:\n"+url); + builder = null; + } + + try { + if (builder != null) { + Process exec = builder.start(); + if (os.equals("linux") && exec.exitValue() == 3) { + // on Linux in case of missing browser + System.out.println("Please open the following link:\n"+url); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + + } +} diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/CryptoHelper.java b/src/main/java/space/b00tload/bsu/dedupe/util/CryptoHelper.java new file mode 100644 index 0000000..91d013e --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/util/CryptoHelper.java @@ -0,0 +1,80 @@ +package space.b00tload.bsu.dedupe.util; + + +import javax.crypto.*; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.*; +import java.nio.file.Path; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; + +public class CryptoHelper { + + /** + * Creates a javax.crypto.SecretKey from a provided password + * @param pass The password + * @return the generated secret key + */ + public static SecretKey createKeyFromPassword(String pass){ + try { + KeySpec spec = new PBEKeySpec(pass.toCharArray(), "abcdefghijklmnop".getBytes(), 65536, 256); // AES-256 + SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + byte[] key = f.generateSecret(spec).getEncoded(); + return new SecretKeySpec(key, "AES"); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + /** + * Saves a java.io.Serializable Object into an encrypted file + * @param obj The object to save + * @param file The Path where to save the file + * @param key The SecretKey (AES) to encrypt the file with + */ + public static void serializeEncrypted(Serializable obj, Path file, SecretKey key){ + try { + if(file.toFile().exists()) file.toFile().delete(); + if(!file.getParent().toFile().exists()) file.getParent().toFile().mkdirs(); + file.toFile().createNewFile(); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv = new IvParameterSpec("abcdefghijklmnop".getBytes()); + cipher.init(Cipher.ENCRYPT_MODE, key, iv); + SealedObject sealedObject = new SealedObject( obj, cipher); + CipherOutputStream cipherOutputStream = new CipherOutputStream( new BufferedOutputStream( new FileOutputStream( file.toFile() ) ), cipher ); + ObjectOutputStream outputStream = new ObjectOutputStream( cipherOutputStream ); + outputStream.writeObject( sealedObject ); + outputStream.close(); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IOException | IllegalBlockSizeException | InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + } + + /** + * Reads an encrypted file into a java.io.Serializable object. + * @param file The java.nio.Path where the encrypted file is stored. + * @param key The SecretKey (AES) to decrypt the file with + * @return The java.io.Serializable object read from the file. + */ + public static Serializable deserializeEncrypted(Path file, SecretKey key) { + Serializable ret; + try { + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec iv = new IvParameterSpec("abcdefghijklmnop".getBytes()); + cipher.init(Cipher.DECRYPT_MODE, key, iv); + CipherInputStream cipherInputStream = new CipherInputStream( new BufferedInputStream( new FileInputStream( file.toFile() ) ), cipher ); + ObjectInputStream inputStream = new ObjectInputStream( cipherInputStream ); + SealedObject sealedObject = (SealedObject) inputStream.readObject(); + ret = (Serializable) sealedObject.getObject(cipher); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IOException | IllegalBlockSizeException | ClassNotFoundException | BadPaddingException | InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + return ret; + } + +} diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/SpotifyCredentials.java b/src/main/java/space/b00tload/bsu/dedupe/util/SpotifyCredentials.java new file mode 100644 index 0000000..a4b37ae --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/util/SpotifyCredentials.java @@ -0,0 +1,70 @@ +package space.b00tload.bsu.dedupe.util; +import se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials; + +import java.io.Serializable; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * A wrapper class for se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials. Implements checking validity of access token. + */ +public class SpotifyCredentials implements Serializable { + + private String accessToken; + private String refreshToken; + private LocalDateTime validUntil; + + /** + * Initializes the class + * @param cred The AuthorizationCodeCredentials to be saved. Recommended for use with recently (last few seconds) generated Credentials + */ + public SpotifyCredentials(AuthorizationCodeCredentials cred){ + this.accessToken = cred.getAccessToken(); + this.refreshToken = cred.getRefreshToken(); + this.validUntil = LocalDateTime.now(Clock.systemDefaultZone()).plusSeconds(cred.getExpiresIn()); + } + + /** + * Get the access token. It becomes invalid after a certain period of time. Check validity with isValid(). + * @return An access token that can be provided in subsequent calls, for example to Spotify Web API services. + */ + public String getAccessToken(){ + return accessToken; + } + + /** + * Get the refresh token. This token can be sent to the Spotify Accounts service in place of an authorization code to retrieve a new access token. + * @return A token that can be sent to the Spotify Accounts service in place of an access token. + */ + public String getRefreshToken(){ + return refreshToken; + } + + /** + * Returns a LocalDateTime which represents the latest point in time when the access token is still valid. + * @return A LocalDateTime representing the latest point in time of access token validity + */ + public LocalDateTime getValidUntil(){ + return validUntil; + } + + /** + * Checks whether the saved access token is still valid for use in calls, for example to the Spotify Web API services. + * @return true if the access token is still valid for use, false if not. + */ + public boolean isValid(){ + return LocalDateTime.now(Clock.systemDefaultZone()).isBefore(getValidUntil()); + } + + /** + * Refreshes the access token. If a new refresh token is provided it will be saved as well. + * @param cred The AuthorizationCodeCredentials to be saved. Recommended for use with recently (last few seconds) generated Credentials + */ + public void refreshCredentials(AuthorizationCodeCredentials cred){ + this.accessToken = cred.getAccessToken(); + if(Objects.nonNull(cred.getRefreshToken())) this.refreshToken = cred.getRefreshToken(); + this.validUntil = LocalDateTime.now(Clock.systemDefaultZone()).plusSeconds(cred.getExpiresIn()); + } + +} \ No newline at end of file diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/TokenHelper.java b/src/main/java/space/b00tload/bsu/dedupe/util/TokenHelper.java new file mode 100644 index 0000000..56a9e79 --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/util/TokenHelper.java @@ -0,0 +1,31 @@ +package space.b00tload.bsu.dedupe.util; + + +import static space.b00tload.bsu.dedupe.SpotifyDedupe.*; + +public class TokenHelper { + + /** + * Manages saving a space.b00tload.bsu.dedupe.util.SpotifyCredentials into "~/.lfm2s/spotify.lfm2scred" using space.b00tload.bsu.dedupe.util.CryptoHelper.serializeEncrypted(...) + * @param cred The space.b00tload.bsu.dedupe.util.SpotifyCredentials to be saved + */ + public static void saveTokens(SpotifyCredentials cred) { + CryptoHelper.serializeEncrypted(cred, CREDENTIAL_LOCATION, CryptoHelper.createKeyFromPassword(TomlConfiguration.getString("spotify.caching.password"))); + } + + /** + * Manages retrieving a space.b00tload.bsu.dedupe.util.SpotifyCredentials from "~/.bsu/dedupe/spotify.bsucred" using space.b00tload.bsu.dedupe.util.CryptoHelper.deserializeEncrypted(...) + * @return The retrieved space.b00tload.bsu.dedupe.util.SpotifyCredentials + */ + public static SpotifyCredentials fetchTokens() { + return (SpotifyCredentials) CryptoHelper.deserializeEncrypted(CREDENTIAL_LOCATION, CryptoHelper.createKeyFromPassword(TomlConfiguration.getString("spotify.caching.password"))); + } + + /** + * Checks whether the saved SpotifyCredentials at "~/.bsu/dedupe/spotify.bsucred" exist + * @return true if file exists, false if not + */ + public static boolean existsTokens(){ + return CREDENTIAL_LOCATION.toFile().exists(); + } +} diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/TomlConfiguration.java b/src/main/java/space/b00tload/bsu/dedupe/util/TomlConfiguration.java new file mode 100644 index 0000000..fd3849c --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/util/TomlConfiguration.java @@ -0,0 +1,82 @@ +package space.b00tload.bsu.dedupe.util; + +import com.electronwill.nightconfig.core.file.FileConfig; +import space.b00tload.bsu.dedupe.exceptions.ConfigException; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.List; + +import static space.b00tload.bsu.dedupe.SpotifyDedupe.CONFIG_BASE; + +public class TomlConfiguration { + + static { + try { + init(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static FileConfig config; + + private static void init() throws IOException { + config = FileConfig.builder(Paths.get(CONFIG_BASE, "config.toml")).defaultResource("/config/default.toml").autosave().autoreload().build(); + config.load(); + checkVersion(); + } + + public static void validate(List required){ + if(isDefault()) throw new ConfigException("You have not modified your config at " + config.getNioPath().toString() + " yet."); + for(String s : required){ + if(!config.contains(s)){ + throw new ConfigException("Missing entry "+ s); + } + } + } + + public static void close() { + config.save(); + config.close(); + } + + public static void checkVersion(){ + String currentVersion = TomlConfiguration.class.getPackage().getImplementationVersion(); + String configVersion = getString("file.version"); + if (currentVersion == null) { + System.out.println("Error: Failed to retrieve current version. Assuming 0.0.1"); + currentVersion = "0.0.1"; + } + if (configVersion == null) { + throw new ConfigException("Invalid config. \"file.version\" is not set."); + } else if (currentVersion.compareTo(configVersion) < 0) { + System.out.println("Software updated. Please check wiki for migration help."); + setString("file.version", currentVersion); + } + } + + public static boolean isDefault(){ + return config.getOrElse("default", false); + } + + public static String getString(String key){ + return config.get(key); + } + + public static void setString(String key, String value){ + config.set(key, value); + } + + public static boolean getBoolean(String key){ + return config.get(key); + } + + public static void setBoolean(String key, boolean value){ + config.set(key, value); + } + + + + +} diff --git a/src/main/java/space/b00tload/bsu/dedupe/util/VersionChecker.java b/src/main/java/space/b00tload/bsu/dedupe/util/VersionChecker.java new file mode 100644 index 0000000..1c14fd0 --- /dev/null +++ b/src/main/java/space/b00tload/bsu/dedupe/util/VersionChecker.java @@ -0,0 +1,45 @@ +package space.b00tload.bsu.dedupe.util; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.commons.io.IOUtils; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class VersionChecker { + + private static final String GITHUB_API_BASE_URL = "https://api.github.com/repos/B00tLoad/SpotifyDedupe"; + + + public static void checkVerion() { + String currentVersion = VersionChecker.class.getPackage().getImplementationVersion(); + String latestVersion = fetchLatestReleaseVersion(); + if (currentVersion == null) { + System.out.println("Error: Failed to retrieve current version"); + } else if (latestVersion == null) { + System.out.println("Error: Failed to retrieve latest release version"); + } else if (currentVersion.compareTo(latestVersion) < 0) { + System.out.println("A new version is available: " + latestVersion); + } else { + System.out.println("You are running the latest version: " + currentVersion); + } + } + + private static String fetchLatestReleaseVersion() { + try (InputStream inputStream = (new URL(GITHUB_API_BASE_URL + "/releases/latest")).openStream()) { + String response = IOUtils.toString(inputStream, StandardCharsets.UTF_8); + JsonObject release = JsonParser.parseString(response).getAsJsonObject(); + return release.get("tag_name").getAsString().substring(1); + } catch (FileNotFoundException ex){ + return null; + } catch (NullPointerException | IOException ex){ + ex.printStackTrace(); + return null; + } + } + +} diff --git a/src/main/resources/config/default.toml b/src/main/resources/config/default.toml new file mode 100644 index 0000000..1beccb4 --- /dev/null +++ b/src/main/resources/config/default.toml @@ -0,0 +1,16 @@ +# Remove after setting your values +default = true + +# Set your client ID and secret from your spotify application +[spotify] +client = "your_client_id" +secret = "your_client_secret" + +# To enable caching auth tokens, set enabled to true and a password +[spotify.caching] +enabled = false +password = "set password here" + +# Ignore, autoset by software +[file] +version = "1.0.0" \ No newline at end of file