diff --git a/.github/workflows/build_status.yml b/.github/workflows/build_status.yml new file mode 100644 index 0000000..8c78e0d --- /dev/null +++ b/.github/workflows/build_status.yml @@ -0,0 +1,29 @@ +name: Build Status + +on: + push: + pull_request: + +jobs: + build: + env: + JAVA_VERSION: 21 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'microsoft' + java-version: ${{ env.JAVA_VERSION }} + cache: 'gradle' + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + - name: Build with Gradle + run: ./gradlew --no-daemon build + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.event.repository.name }}-${{ github.sha }} + path: | + build/libs/*.jar + !build/libs/*-slim.jar diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml new file mode 100644 index 0000000..3227f52 --- /dev/null +++ b/.github/workflows/ghcr.yml @@ -0,0 +1,57 @@ +name: Upload to GHCR + +on: + push: + branches: + - '*' + tags: + - '*' + +jobs: + build: + env: + JAVA_VERSION: 21 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'microsoft' + java-version: ${{ env.JAVA_VERSION }} + cache: 'gradle' + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + - name: Build with Gradle + run: ./gradlew --no-daemon installShadowDist + - name: Setup Docker BuildX + uses: docker/setup-buildx-action@v3 + - name: Log into container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + # default values + type=schedule + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + # set latest tag for default branch + type=raw,value=latest,enable={{is_default_branch}} + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + JAVA_VERSION=${{ env.JAVA_VERSION }} + VERSION=${{ github.ref_name }} diff --git a/.gitignore b/.gitignore index 7e6e3b3..cb303f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,7 @@ build/ !**/src/test/**/build/ ### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ +.idea/ *.iws *.iml *.ipr @@ -41,6 +38,7 @@ bin/ ### Mac OS ### .DS_Store +run/ config.json downloads/ .idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22e09db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +ARG JAVA_VERSON=21 + +FROM mcr.microsoft.com/openjdk/jdk:${JAVA_VERSON}-distroless + +WORKDIR /app + +VOLUME /app/data + +ENV CONFIG_FILE=/app/config.json + +COPY build/install/Ember-shadow/lib/Ember-*.jar Ember.jar + +ENTRYPOINT ["java", "-XshowSettings:vm", "-XX:MinRAMPercentage=20", "-XX:MaxRAMPercentage=95", "-jar", "Ember.jar", "--config", "${CONFIG_FILE}"] diff --git a/README.md b/README.md index 59ed383..7394a26 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Ember +# Ember [![Build Status](https://github.com/forgecraft/Ember/actions/workflows/build_status.yml/badge.svg)](https://github.com/forgecraft/Ember/actions/workflows/build_status.yml) Ember is our resident ForgeCraft Discord bot. It's a relatively simple bot that just helps with the day to day running of the server. It's written in Java and JavaCord. @@ -19,4 +19,4 @@ If you would like to contribute to Ember, please feel free to fork the repositor ## License -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. +This project is licensed under the MIT License - see the [LICENSE file](LICENSE.md) for details. diff --git a/build.gradle.kts b/build.gradle.kts index 3c5d064..418d5f8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,14 +3,23 @@ import org.jetbrains.gradle.ext.runConfigurations import org.jetbrains.gradle.ext.settings plugins { - id("java") - id("application") + java + application id("org.jetbrains.gradle.plugin.idea-ext") version "1.1.3" + id("org.jooq.jooq-codegen-gradle") version "3.19.5" + id("com.github.johnrengelman.shadow") version "8.1.1" } +val javaVersion = 21 + group = "net.forgecraft.services" version = "1.0.0" +sourceSets { + create("generated") + create("database") +} + repositories { mavenCentral() } @@ -22,28 +31,142 @@ dependencies { implementation("org.javacord:javacord:3.8.0") implementation("org.apache.logging.log4j:log4j-to-slf4j:2.22.1") - implementation("ch.qos.logback:logback-classic:1.5.0") + sourceSets.getByName("database").implementationConfigurationName("org.apache.logging.log4j:log4j-to-slf4j:2.22.1") + + runtimeOnly("ch.qos.logback:logback-classic:1.5.0") + sourceSets.getByName("database").runtimeOnlyConfigurationName("ch.qos.logback:logback-classic:1.5.0") // Jackson implementation("com.fasterxml.jackson.core:jackson-core:2.16.1") implementation("com.fasterxml.jackson.core:jackson-databind:2.16.1") + runtimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.16.1") + + implementation("org.jooq:jooq:3.19.5") + sourceSets.getByName("database").implementationConfigurationName("org.jooq:jooq:3.19.5") + sourceSets.getByName("generated").implementationConfigurationName("org.jooq:jooq:3.19.5") + + runtimeOnly("org.xerial:sqlite-jdbc:3.45.1.0") + sourceSets.getByName("database").runtimeOnlyConfigurationName("org.xerial:sqlite-jdbc:3.45.1.0") + jooqCodegen("org.xerial:sqlite-jdbc:3.45.1.0") // Utils implementation("commons-io:commons-io:2.15.1") - implementation("org.jetbrains:annotations:24.1.0") + implementation("com.google.guava:guava:33.0.0-jre") + implementation("it.unimi.dsi:fastutil:8.5.13") + implementation("com.electronwill.night-config:toml:3.6.7") + implementation("io.github.matyrobbrt:curseforgeapi:1.8.0") + + implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1") + + implementation("info.picocli:picocli:4.7.5") + annotationProcessor("info.picocli:picocli-codegen:4.7.5") + + compileOnly("org.jetbrains:annotations:24.1.0") + sourceSets.getByName("database").compileOnlyConfigurationName("org.jetbrains:annotations:24.1.0") + + implementation(sourceSets.getByName("database").output) + implementation(sourceSets.getByName("generated").output) +} + +tasks.named("shadowJar", Jar::class).configure { + archiveClassifier.set("") + archiveVersion.set(project.version.toString()) +} + +tasks.named("jar", Jar::class).configure { + archiveClassifier.set("slim") + + manifest.attributes( + "Specification-Vendor" to "ForgeCraft (https://forgecraft.net)", + "Implementation-Vendor" to "https://github.com/forgecraft/Ember", + "Implementation-Title" to "Ember", + "Implementation-Version" to project.version.toString(), + ) +} + +tasks.withType(JavaCompile::class).configureEach { + options.encoding = "UTF-8" + options.release.set(javaVersion) +} + +tasks.named("compileJava", JavaCompile::class).configure { + options.compilerArgs.add("-Aproject=${project.group}/${project.name}") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(javaVersion) + } +} + +jooq { + version = "3.19.5" + + configuration { + + jdbc { + driver = "org.sqlite.JDBC" + url = "jdbc:sqlite:${project.file("run/data/sqlite.db").path}" + } + + generator { + database { + name = "org.jooq.meta.sqlite.SQLiteDatabase" + includes = ".*" + + // exclude the migrations table from schema generation + excludes = """ + __migrations + """.trimIndent() + } + + target { + packageName = "net.forgecraft.services.ember.db.schema" + directory = project.file("src/generated/java").path + } + } + } + + project.file("run/data").mkdirs() } -// Runs for intelij idea.project.settings { runConfigurations { register("Start Ember", Application::class) { + project.file("run").mkdirs() mainClass = "net.forgecraft.services.ember.Main" programParameters = "--config=config.json" - moduleName = "ember.main" + moduleName = "${project.name}.main" + workingDirectory = project.file("run").path + } + + register("Bootstrap Database", Application::class) { + project.file("run").mkdirs() + mainClass = "net.forgecraft.services.ember.db.Main" + moduleName = "${project.name}.database" + workingDirectory = project.file("run").path } } } +// configure the java application plugin for CI and anyone not using IDEA +application { + mainClass.set("net.forgecraft.services.ember.Main") + executableDir = project.file("run").path +} + +tasks.named("run", JavaExec::class) { + args("--config=config.json") +} + +tasks.register("boostrapDatabase", JavaExec::class) { + group = "application" + mainClass.set("net.forgecraft.services.ember.db.Main") + classpath = sourceSets.getByName("database").runtimeClasspath + workingDir = project.file("run") +} +tasks.getByName("jooqCodegen").dependsOn("boostrapDatabase") + tasks.test { useJUnitPlatform() } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e583..e644113 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a8c7e0f..b82aa23 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Wed Feb 21 21:24:02 GMT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +214,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd3..25da30d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +41,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle.kts b/settings.gradle.kts index a5172b5..94a5890 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,5 @@ -rootProject.name = "ember" +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} +rootProject.name = "Ember" diff --git a/src/database/java/net/forgecraft/services/ember/db/DatabaseManager.java b/src/database/java/net/forgecraft/services/ember/db/DatabaseManager.java new file mode 100644 index 0000000..29fc03e --- /dev/null +++ b/src/database/java/net/forgecraft/services/ember/db/DatabaseManager.java @@ -0,0 +1,18 @@ +package net.forgecraft.services.ember.db; + +import net.forgecraft.services.ember.db.migration.MigrationHelper; +import org.jooq.CloseableDSLContext; + +public class DatabaseManager { + + public static void bootstrapDatabase(CloseableDSLContext ctx) { + + // foreign key support in SQLite must be enabled and will be immediately committed, + // cannot be done in a transaction + ctx.execute("PRAGMA foreign_keys = ON;"); + + MigrationHelper.applyPendingMigrations(ctx); + + ctx.execute("PRAGMA optimize;"); + } +} diff --git a/src/database/java/net/forgecraft/services/ember/db/Main.java b/src/database/java/net/forgecraft/services/ember/db/Main.java new file mode 100644 index 0000000..3f51a97 --- /dev/null +++ b/src/database/java/net/forgecraft/services/ember/db/Main.java @@ -0,0 +1,19 @@ +package net.forgecraft.services.ember.db; + +import org.jooq.impl.DSL; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class Main { + + public static void main(String[] args) throws IOException { + Files.createDirectories(Path.of("data")); + String dbUrl = "jdbc:sqlite:data/sqlite.db"; + + try (var ctx = DSL.using(dbUrl)) { + DatabaseManager.bootstrapDatabase(ctx); + } + } +} diff --git a/src/database/java/net/forgecraft/services/ember/db/migration/Migration.java b/src/database/java/net/forgecraft/services/ember/db/migration/Migration.java new file mode 100644 index 0000000..00009d7 --- /dev/null +++ b/src/database/java/net/forgecraft/services/ember/db/migration/Migration.java @@ -0,0 +1,98 @@ +package net.forgecraft.services.ember.db.migration; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.IntPredicate; + +public final class Migration implements IntPredicate, Comparable { + + private final String name; + private final int version; + private final Path migrationPath; + + public Migration(String name, int version, Path migrationPath) { + this.name = name; + this.version = version; + this.migrationPath = migrationPath; + } + + @Override + public boolean test(int value) { + return version() > value; + } + + @Override + public int compareTo(@NotNull Migration o) { + return Comparator.comparingInt(Migration::version).compare(this, o); + } + + public String name() { + return name; + } + + public int version() { + return version; + } + + @Nullable + public List getUp() throws IOException { + return resolve(Type.UP); + } + + @Nullable + public List getDown() throws IOException { + return resolve(Type.DOWN); + } + + @Nullable + private List resolve(Type type) throws IOException { + var filePath = this.migrationPath.resolve(type.getFileName()); + if (Files.isRegularFile(filePath, LinkOption.NOFOLLOW_LINKS)) { + // strip out comments and empty lines + var fileContent = Files.readAllLines(filePath).stream().map(s -> s.replaceAll("--.+", "")).filter(s -> !s.isBlank()).toList(); + + // manually split statements because SQLite JDBC driver does not support multiple statements in a single execute + List statements = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + for (String line : fileContent) { + int idx = line.indexOf(';'); + while (idx != -1) { + sb.append(line, 0, idx).append(';'); + statements.add(sb.toString()); + sb.setLength(0); + + line = line.substring(idx + 1); + idx = line.indexOf(';'); + } + + sb.append(line); + sb.append('\n'); + } + return List.copyOf(statements); + } + return null; + } + + public enum Type { + UP("up.sql"), + DOWN("down.sql"); + + private final String fileName; + + Type(String fileName) { + this.fileName = fileName; + } + + public String getFileName() { + return fileName; + } + } +} diff --git a/src/database/java/net/forgecraft/services/ember/db/migration/MigrationHelper.java b/src/database/java/net/forgecraft/services/ember/db/migration/MigrationHelper.java new file mode 100644 index 0000000..fc6e80d --- /dev/null +++ b/src/database/java/net/forgecraft/services/ember/db/migration/MigrationHelper.java @@ -0,0 +1,142 @@ +package net.forgecraft.services.ember.db.migration; + +import org.jooq.CloseableDSLContext; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.regex.Pattern; + +public class MigrationHelper { + + public static final String MIGRATIONS_TABLE_NAME = "__migrations"; + public static final Pattern VALID_MIGRATION_NAME = Pattern.compile("\\d{3,}_.+"); + + public static void applyPendingMigrations(CloseableDSLContext ctx) { + + // create migrations table programmatically, then apply pending migrations + ctx.createTableIfNotExists(MIGRATIONS_TABLE_NAME) + .column("id", SQLDataType.INTEGER.identity(true)) + .column("version", SQLDataType.INTEGER.notNull()) + .column("name", SQLDataType.VARCHAR(255).notNull()) + .constraints( + DSL.unique("name"), + DSL.unique("version") + ) + .execute(); + + int currentVersion = getCurrentVersion(ctx); + + List pendingMigrations; + + try { + var rs = MigrationHelper.class.getClassLoader().getResource("_donotremovethisisahack"); + if(rs == null) { + throw new IllegalStateException("unable to find own file path"); + } + var jarRoot = Path.of(rs.toURI()).getParent(); + + pendingMigrations = getPendingMigrations(currentVersion, jarRoot.resolve("migrations")); + //TODO logging + System.out.println("Found " + pendingMigrations.size() + " pending migrations."); + } catch (IOException e) { + // TODO logging + throw new UncheckedIOException("unable to apply migrations", e); + } catch (URISyntaxException e) { + // should NEVER happen + throw new RuntimeException(e); + } + + for (Migration migration : pendingMigrations) { + try { + ctx.transaction(trx -> { + var up = migration.getUp(); + if (up == null) { + //TODO logging and skip? + throw new IllegalStateException("Up migration not found for " + migration.name()); + } + + for (String statement : up) { + trx.dsl().execute(statement); + } + + trx.dsl() + .insertInto(DSL.table(MIGRATIONS_TABLE_NAME), DSL.field("version"), DSL.field("name")) + .values(migration.version(), migration.name()) + .execute(); + }); + } catch (RuntimeException e) { // DataAccessException + throw new RuntimeException("Failed to apply migration " + migration.name(), e); + } + } + } + + private static List getPendingMigrations(int currentVersion, Path migrationsDir) throws IOException { + var all = parseMigrations(migrationsDir); + //TODO debug logging + System.out.println("Found " + all.size() + " migrations."); + + return all.stream().filter(migration -> migration.test(currentVersion)).sorted().toList(); + } + + private static int getCurrentVersion(CloseableDSLContext ctx) { + + var result = ctx.select(DSL.max(DSL.field("version"))).from(MIGRATIONS_TABLE_NAME).fetchOne(); + + if (result == null) { + return -1; + } + + Integer max = (Integer) result.value1(); + + return max != null ? max : -1; + } + + public static List parseMigrations(Path migrationsDir) throws IOException { + if (!Files.isDirectory(migrationsDir, LinkOption.NOFOLLOW_LINKS)) { + //TODO logging + System.err.println("Migrations directory not found at " + migrationsDir.toAbsolutePath()); + return List.of(); + } + var migrationsDirName = migrationsDir.getFileName().toString(); + + List migrations = new ArrayList<>(); + Files.walkFileTree(migrationsDir, EnumSet.noneOf(FileVisitOption.class), 2, new SimpleFileVisitor<>() { + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + var dirName = dir.getFileName().toString(); + + // skip the root dir + if(dirName.equals(migrationsDirName)) { + return FileVisitResult.CONTINUE; + } + + + if (!VALID_MIGRATION_NAME.asMatchPredicate().test(dirName)) { + return FileVisitResult.SKIP_SUBTREE; + } + + try { + var migrationVersion = Integer.parseInt(dirName.substring(0, dirName.indexOf('_')), 10); + migrations.add(new Migration(dirName, migrationVersion, dir)); + } catch (NumberFormatException e) { + return FileVisitResult.SKIP_SUBTREE; + } + + return FileVisitResult.CONTINUE; + } + }); + + Collections.sort(migrations); + return migrations; + } +} diff --git a/src/database/resources/_donotremovethisisahack b/src/database/resources/_donotremovethisisahack new file mode 100644 index 0000000..e69de29 diff --git a/src/database/resources/migrations/000_Init/down.sql b/src/database/resources/migrations/000_Init/down.sql new file mode 100644 index 0000000..8e95be6 --- /dev/null +++ b/src/database/resources/migrations/000_Init/down.sql @@ -0,0 +1,15 @@ +DROP TABLE IF EXISTS discord_users; + +DROP TABLE IF EXISTS mods; + +DROP TABLE IF EXISTS mod_owners; +DROP INDEX IF EXISTS mod_owners_by_discord_user; +DROP INDEX IF EXISTS mod_owners_by_mod_id; + +DROP TABLE IF EXISTS mod_files; +DROP INDEX IF EXISTS mod_files_by_mod_id; +DROP INDEX IF EXISTS mod_files_by_uploader_id; +DROP INDEX IF EXISTS mod_files_by_active; + +DROP TABLE IF EXISTS audit_log; +DROP INDEX IF EXISTS audit_log_by_mod_id; diff --git a/src/database/resources/migrations/000_Init/up.sql b/src/database/resources/migrations/000_Init/up.sql new file mode 100644 index 0000000..ba986a3 --- /dev/null +++ b/src/database/resources/migrations/000_Init/up.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS discord_users ( + snowflake BIGINT NOT NULL PRIMARY KEY, + display_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS mods ( + id TEXT NOT NULL PRIMARY KEY, + project_url TEXT, + issues_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS mod_owners ( + id INTEGER PRIMARY KEY, + mod_id TEXT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(mod_id) REFERENCES mods(id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY(user_id) REFERENCES discord_users(snowflake) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS mod_owners_by_discord_user ON mod_owners (user_id); +CREATE INDEX IF NOT EXISTS mod_owners_by_mod_id ON mod_owners (mod_id); + +CREATE TABLE IF NOT EXISTS mod_files ( + id INTEGER PRIMARY KEY, + mod_id TEXT NOT NULL, + uploader_id BIGINT NOT NULL, + mod_version TEXT NOT NULL, + active BOOLEAN DEFAULT FALSE, + file_name TEXT NOT NULL, -- file name as uploaded + sha_512 BLOB NOT NULL, -- hash of file contents + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(mod_id) REFERENCES mods(id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY(uploader_id) REFERENCES discord_users(snowflake) ON DELETE CASCADE ON UPDATE CASCADE, + UNIQUE(mod_id, sha_512) +); +CREATE INDEX IF NOT EXISTS mod_files_by_mod_id ON mod_files (mod_id); +CREATE INDEX IF NOT EXISTS mod_files_by_uploader_id ON mod_files (uploader_id); +CREATE INDEX IF NOT EXISTS mod_files_by_active ON mod_files (active); + +-- no ON DELETE CASCADE to keep audit log intact if user is deleted +-- explicitly auto-increment the id, this is different from the default behavior of only requiring an unused row ID. +-- it will not reuse IDs, as well as guarantee that the new ID is greater than the largest previously used ID. +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id BIGINT NOT NULL, + action_type TEXT NOT NULL, + data JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(user_id) REFERENCES discord_users(snowflake) ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS audit_log_by_user_id ON audit_log (user_id); diff --git a/src/database/resources/migrations/001_FileHashIndex/down.sql b/src/database/resources/migrations/001_FileHashIndex/down.sql new file mode 100644 index 0000000..46017f7 --- /dev/null +++ b/src/database/resources/migrations/001_FileHashIndex/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS mod_files_by_modid_and_sha512; diff --git a/src/database/resources/migrations/001_FileHashIndex/up.sql b/src/database/resources/migrations/001_FileHashIndex/up.sql new file mode 100644 index 0000000..22db14d --- /dev/null +++ b/src/database/resources/migrations/001_FileHashIndex/up.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS mod_files_by_modid_and_sha512 on mod_files (mod_id, sha_512); diff --git a/src/database/resources/migrations/002_ModApprovals/down.sql b/src/database/resources/migrations/002_ModApprovals/down.sql new file mode 100644 index 0000000..a68ee11 --- /dev/null +++ b/src/database/resources/migrations/002_ModApprovals/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS approval_queue; diff --git a/src/database/resources/migrations/002_ModApprovals/up.sql b/src/database/resources/migrations/002_ModApprovals/up.sql new file mode 100644 index 0000000..9c70890 --- /dev/null +++ b/src/database/resources/migrations/002_ModApprovals/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS approval_queue ( + message_id BIGINT NOT NULL PRIMARY KEY, + mod_file_id INTEGER UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (mod_file_id) REFERENCES mod_files (id) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/DefaultCatalog.java b/src/generated/java/net/forgecraft/services/ember/db/schema/DefaultCatalog.java new file mode 100644 index 0000000..24aa59d --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/DefaultCatalog.java @@ -0,0 +1,54 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema; + + +import java.util.Arrays; +import java.util.List; + +import org.jooq.Constants; +import org.jooq.Schema; +import org.jooq.impl.CatalogImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class DefaultCatalog extends CatalogImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of DEFAULT_CATALOG + */ + public static final DefaultCatalog DEFAULT_CATALOG = new DefaultCatalog(); + + /** + * The schema DEFAULT_SCHEMA. + */ + public final DefaultSchema DEFAULT_SCHEMA = DefaultSchema.DEFAULT_SCHEMA; + + /** + * No further instances allowed + */ + private DefaultCatalog() { + super(""); + } + + @Override + public final List getSchemas() { + return Arrays.asList( + DefaultSchema.DEFAULT_SCHEMA + ); + } + + /** + * A reference to the 3.19 minor release of the code generator. If this + * doesn't compile, it's because the runtime library uses an older minor + * release, namely: 3.19. You can turn off the generation of this reference + * by specifying /configuration/generator/generate/jooqVersionReference + */ + private static final String REQUIRE_RUNTIME_JOOQ_VERSION = Constants.VERSION_3_19; +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/DefaultSchema.java b/src/generated/java/net/forgecraft/services/ember/db/schema/DefaultSchema.java new file mode 100644 index 0000000..5c2e410 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/DefaultSchema.java @@ -0,0 +1,89 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema; + + +import java.util.Arrays; +import java.util.List; + +import net.forgecraft.services.ember.db.schema.tables.ApprovalQueue; +import net.forgecraft.services.ember.db.schema.tables.AuditLog; +import net.forgecraft.services.ember.db.schema.tables.DiscordUsers; +import net.forgecraft.services.ember.db.schema.tables.ModFiles; +import net.forgecraft.services.ember.db.schema.tables.ModOwners; +import net.forgecraft.services.ember.db.schema.tables.Mods; + +import org.jooq.Catalog; +import org.jooq.Table; +import org.jooq.impl.SchemaImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class DefaultSchema extends SchemaImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of DEFAULT_SCHEMA + */ + public static final DefaultSchema DEFAULT_SCHEMA = new DefaultSchema(); + + /** + * The table approval_queue. + */ + public final ApprovalQueue APPROVAL_QUEUE = ApprovalQueue.APPROVAL_QUEUE; + + /** + * The table audit_log. + */ + public final AuditLog AUDIT_LOG = AuditLog.AUDIT_LOG; + + /** + * The table discord_users. + */ + public final DiscordUsers DISCORD_USERS = DiscordUsers.DISCORD_USERS; + + /** + * The table mod_files. + */ + public final ModFiles MOD_FILES = ModFiles.MOD_FILES; + + /** + * The table mod_owners. + */ + public final ModOwners MOD_OWNERS = ModOwners.MOD_OWNERS; + + /** + * The table mods. + */ + public final Mods MODS = Mods.MODS; + + /** + * No further instances allowed + */ + private DefaultSchema() { + super("", null); + } + + + @Override + public Catalog getCatalog() { + return DefaultCatalog.DEFAULT_CATALOG; + } + + @Override + public final List> getTables() { + return Arrays.asList( + ApprovalQueue.APPROVAL_QUEUE, + AuditLog.AUDIT_LOG, + DiscordUsers.DISCORD_USERS, + ModFiles.MOD_FILES, + ModOwners.MOD_OWNERS, + Mods.MODS + ); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/Indexes.java b/src/generated/java/net/forgecraft/services/ember/db/schema/Indexes.java new file mode 100644 index 0000000..203f9b9 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/Indexes.java @@ -0,0 +1,34 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema; + + +import net.forgecraft.services.ember.db.schema.tables.AuditLog; +import net.forgecraft.services.ember.db.schema.tables.ModFiles; +import net.forgecraft.services.ember.db.schema.tables.ModOwners; + +import org.jooq.Index; +import org.jooq.OrderField; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; + + +/** + * A class modelling indexes of tables in the default schema. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Indexes { + + // ------------------------------------------------------------------------- + // INDEX definitions + // ------------------------------------------------------------------------- + + public static final Index AUDIT_LOG_BY_USER_ID = Internal.createIndex(DSL.name("audit_log_by_user_id"), AuditLog.AUDIT_LOG, new OrderField[] { AuditLog.AUDIT_LOG.USER_ID }, false); + public static final Index MOD_FILES_BY_ACTIVE = Internal.createIndex(DSL.name("mod_files_by_active"), ModFiles.MOD_FILES, new OrderField[] { ModFiles.MOD_FILES.ACTIVE }, false); + public static final Index MOD_FILES_BY_MOD_ID = Internal.createIndex(DSL.name("mod_files_by_mod_id"), ModFiles.MOD_FILES, new OrderField[] { ModFiles.MOD_FILES.MOD_ID }, false); + public static final Index MOD_FILES_BY_MODID_AND_SHA512 = Internal.createIndex(DSL.name("mod_files_by_modid_and_sha512"), ModFiles.MOD_FILES, new OrderField[] { ModFiles.MOD_FILES.MOD_ID, ModFiles.MOD_FILES.SHA_512 }, true); + public static final Index MOD_FILES_BY_UPLOADER_ID = Internal.createIndex(DSL.name("mod_files_by_uploader_id"), ModFiles.MOD_FILES, new OrderField[] { ModFiles.MOD_FILES.UPLOADER_ID }, false); + public static final Index MOD_OWNERS_BY_DISCORD_USER = Internal.createIndex(DSL.name("mod_owners_by_discord_user"), ModOwners.MOD_OWNERS, new OrderField[] { ModOwners.MOD_OWNERS.USER_ID }, false); + public static final Index MOD_OWNERS_BY_MOD_ID = Internal.createIndex(DSL.name("mod_owners_by_mod_id"), ModOwners.MOD_OWNERS, new OrderField[] { ModOwners.MOD_OWNERS.MOD_ID }, false); +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/Keys.java b/src/generated/java/net/forgecraft/services/ember/db/schema/Keys.java new file mode 100644 index 0000000..dc70276 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/Keys.java @@ -0,0 +1,55 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema; + + +import net.forgecraft.services.ember.db.schema.tables.ApprovalQueue; +import net.forgecraft.services.ember.db.schema.tables.AuditLog; +import net.forgecraft.services.ember.db.schema.tables.DiscordUsers; +import net.forgecraft.services.ember.db.schema.tables.ModFiles; +import net.forgecraft.services.ember.db.schema.tables.ModOwners; +import net.forgecraft.services.ember.db.schema.tables.Mods; +import net.forgecraft.services.ember.db.schema.tables.records.ApprovalQueueRecord; +import net.forgecraft.services.ember.db.schema.tables.records.AuditLogRecord; +import net.forgecraft.services.ember.db.schema.tables.records.DiscordUsersRecord; +import net.forgecraft.services.ember.db.schema.tables.records.ModFilesRecord; +import net.forgecraft.services.ember.db.schema.tables.records.ModOwnersRecord; +import net.forgecraft.services.ember.db.schema.tables.records.ModsRecord; + +import org.jooq.ForeignKey; +import org.jooq.TableField; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; + + +/** + * A class modelling foreign key relationships and constraints of tables in the + * default schema. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Keys { + + // ------------------------------------------------------------------------- + // UNIQUE and PRIMARY KEY definitions + // ------------------------------------------------------------------------- + + public static final UniqueKey APPROVAL_QUEUE__PK_APPROVAL_QUEUE = Internal.createUniqueKey(ApprovalQueue.APPROVAL_QUEUE, DSL.name("pk_approval_queue"), new TableField[] { ApprovalQueue.APPROVAL_QUEUE.MESSAGE_ID }, true); + public static final UniqueKey AUDIT_LOG__PK_AUDIT_LOG = Internal.createUniqueKey(AuditLog.AUDIT_LOG, DSL.name("pk_audit_log"), new TableField[] { AuditLog.AUDIT_LOG.ID }, true); + public static final UniqueKey DISCORD_USERS__PK_DISCORD_USERS = Internal.createUniqueKey(DiscordUsers.DISCORD_USERS, DSL.name("pk_discord_users"), new TableField[] { DiscordUsers.DISCORD_USERS.SNOWFLAKE }, true); + public static final UniqueKey MOD_FILES__PK_MOD_FILES = Internal.createUniqueKey(ModFiles.MOD_FILES, DSL.name("pk_mod_files"), new TableField[] { ModFiles.MOD_FILES.ID }, true); + public static final UniqueKey MOD_OWNERS__PK_MOD_OWNERS = Internal.createUniqueKey(ModOwners.MOD_OWNERS, DSL.name("pk_mod_owners"), new TableField[] { ModOwners.MOD_OWNERS.ID }, true); + public static final UniqueKey MODS__PK_MODS = Internal.createUniqueKey(Mods.MODS, DSL.name("pk_mods"), new TableField[] { Mods.MODS.ID }, true); + + // ------------------------------------------------------------------------- + // FOREIGN KEY definitions + // ------------------------------------------------------------------------- + + public static final ForeignKey APPROVAL_QUEUE__FK_APPROVAL_QUEUE_PK_MOD_FILES = Internal.createForeignKey(ApprovalQueue.APPROVAL_QUEUE, DSL.name("fk_approval_queue_pk_mod_files"), new TableField[] { ApprovalQueue.APPROVAL_QUEUE.MOD_FILE_ID }, Keys.MOD_FILES__PK_MOD_FILES, new TableField[] { ModFiles.MOD_FILES.ID }, true); + public static final ForeignKey AUDIT_LOG__FK_AUDIT_LOG_PK_DISCORD_USERS = Internal.createForeignKey(AuditLog.AUDIT_LOG, DSL.name("fk_audit_log_pk_discord_users"), new TableField[] { AuditLog.AUDIT_LOG.USER_ID }, Keys.DISCORD_USERS__PK_DISCORD_USERS, new TableField[] { DiscordUsers.DISCORD_USERS.SNOWFLAKE }, true); + public static final ForeignKey MOD_FILES__FK_MOD_FILES_PK_DISCORD_USERS = Internal.createForeignKey(ModFiles.MOD_FILES, DSL.name("fk_mod_files_pk_discord_users"), new TableField[] { ModFiles.MOD_FILES.UPLOADER_ID }, Keys.DISCORD_USERS__PK_DISCORD_USERS, new TableField[] { DiscordUsers.DISCORD_USERS.SNOWFLAKE }, true); + public static final ForeignKey MOD_FILES__FK_MOD_FILES_PK_MODS = Internal.createForeignKey(ModFiles.MOD_FILES, DSL.name("fk_mod_files_pk_mods"), new TableField[] { ModFiles.MOD_FILES.MOD_ID }, Keys.MODS__PK_MODS, new TableField[] { Mods.MODS.ID }, true); + public static final ForeignKey MOD_OWNERS__FK_MOD_OWNERS_PK_DISCORD_USERS = Internal.createForeignKey(ModOwners.MOD_OWNERS, DSL.name("fk_mod_owners_pk_discord_users"), new TableField[] { ModOwners.MOD_OWNERS.USER_ID }, Keys.DISCORD_USERS__PK_DISCORD_USERS, new TableField[] { DiscordUsers.DISCORD_USERS.SNOWFLAKE }, true); + public static final ForeignKey MOD_OWNERS__FK_MOD_OWNERS_PK_MODS = Internal.createForeignKey(ModOwners.MOD_OWNERS, DSL.name("fk_mod_owners_pk_mods"), new TableField[] { ModOwners.MOD_OWNERS.MOD_ID }, Keys.MODS__PK_MODS, new TableField[] { Mods.MODS.ID }, true); +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/Tables.java b/src/generated/java/net/forgecraft/services/ember/db/schema/Tables.java new file mode 100644 index 0000000..3a8f8bf --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/Tables.java @@ -0,0 +1,50 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema; + + +import net.forgecraft.services.ember.db.schema.tables.ApprovalQueue; +import net.forgecraft.services.ember.db.schema.tables.AuditLog; +import net.forgecraft.services.ember.db.schema.tables.DiscordUsers; +import net.forgecraft.services.ember.db.schema.tables.ModFiles; +import net.forgecraft.services.ember.db.schema.tables.ModOwners; +import net.forgecraft.services.ember.db.schema.tables.Mods; + + +/** + * Convenience access to all tables in the default schema. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Tables { + + /** + * The table approval_queue. + */ + public static final ApprovalQueue APPROVAL_QUEUE = ApprovalQueue.APPROVAL_QUEUE; + + /** + * The table audit_log. + */ + public static final AuditLog AUDIT_LOG = AuditLog.AUDIT_LOG; + + /** + * The table discord_users. + */ + public static final DiscordUsers DISCORD_USERS = DiscordUsers.DISCORD_USERS; + + /** + * The table mod_files. + */ + public static final ModFiles MOD_FILES = ModFiles.MOD_FILES; + + /** + * The table mod_owners. + */ + public static final ModOwners MOD_OWNERS = ModOwners.MOD_OWNERS; + + /** + * The table mods. + */ + public static final Mods MODS = Mods.MODS; +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ApprovalQueue.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ApprovalQueue.java new file mode 100644 index 0000000..0c89c51 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ApprovalQueue.java @@ -0,0 +1,286 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables; + + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import net.forgecraft.services.ember.db.schema.DefaultSchema; +import net.forgecraft.services.ember.db.schema.Keys; +import net.forgecraft.services.ember.db.schema.tables.ModFiles.ModFilesPath; +import net.forgecraft.services.ember.db.schema.tables.records.ApprovalQueueRecord; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.InverseForeignKey; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ApprovalQueue extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of approval_queue + */ + public static final ApprovalQueue APPROVAL_QUEUE = new ApprovalQueue(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return ApprovalQueueRecord.class; + } + + /** + * The column approval_queue.message_id. + */ + public final TableField MESSAGE_ID = createField(DSL.name("message_id"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column approval_queue.mod_file_id. + */ + public final TableField MOD_FILE_ID = createField(DSL.name("mod_file_id"), SQLDataType.INTEGER.nullable(false), this, ""); + + /** + * The column approval_queue.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.LOCALDATETIME(0).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.LOCALDATETIME)), this, ""); + + private ApprovalQueue(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private ApprovalQueue(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased approval_queue table reference + */ + public ApprovalQueue(String alias) { + this(DSL.name(alias), APPROVAL_QUEUE); + } + + /** + * Create an aliased approval_queue table reference + */ + public ApprovalQueue(Name alias) { + this(alias, APPROVAL_QUEUE); + } + + /** + * Create a approval_queue table reference + */ + public ApprovalQueue() { + this(DSL.name("approval_queue"), null); + } + + public ApprovalQueue(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, APPROVAL_QUEUE); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class ApprovalQueuePath extends ApprovalQueue implements Path { + + private static final long serialVersionUID = 1L; + public ApprovalQueuePath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private ApprovalQueuePath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public ApprovalQueuePath as(String alias) { + return new ApprovalQueuePath(DSL.name(alias), this); + } + + @Override + public ApprovalQueuePath as(Name alias) { + return new ApprovalQueuePath(alias, this); + } + + @Override + public ApprovalQueuePath as(Table alias) { + return new ApprovalQueuePath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : DefaultSchema.DEFAULT_SCHEMA; + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.APPROVAL_QUEUE__PK_APPROVAL_QUEUE; + } + + @Override + public List> getReferences() { + return Arrays.asList(Keys.APPROVAL_QUEUE__FK_APPROVAL_QUEUE_PK_MOD_FILES); + } + + private transient ModFilesPath _modFiles; + + /** + * Get the implicit join path to the mod_files table. + */ + public ModFilesPath modFiles() { + if (_modFiles == null) + _modFiles = new ModFilesPath(this, Keys.APPROVAL_QUEUE__FK_APPROVAL_QUEUE_PK_MOD_FILES, null); + + return _modFiles; + } + + @Override + public ApprovalQueue as(String alias) { + return new ApprovalQueue(DSL.name(alias), this); + } + + @Override + public ApprovalQueue as(Name alias) { + return new ApprovalQueue(alias, this); + } + + @Override + public ApprovalQueue as(Table alias) { + return new ApprovalQueue(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public ApprovalQueue rename(String name) { + return new ApprovalQueue(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public ApprovalQueue rename(Name name) { + return new ApprovalQueue(name, null); + } + + /** + * Rename this table + */ + @Override + public ApprovalQueue rename(Table name) { + return new ApprovalQueue(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ApprovalQueue where(Condition condition) { + return new ApprovalQueue(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ApprovalQueue where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ApprovalQueue where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ApprovalQueue where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ApprovalQueue where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ApprovalQueue where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ApprovalQueue where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ApprovalQueue where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ApprovalQueue whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ApprovalQueue whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/AuditLog.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/AuditLog.java new file mode 100644 index 0000000..ee1f1a9 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/AuditLog.java @@ -0,0 +1,310 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables; + + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import net.forgecraft.services.ember.db.schema.DefaultSchema; +import net.forgecraft.services.ember.db.schema.Indexes; +import net.forgecraft.services.ember.db.schema.Keys; +import net.forgecraft.services.ember.db.schema.tables.DiscordUsers.DiscordUsersPath; +import net.forgecraft.services.ember.db.schema.tables.records.AuditLogRecord; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.Identity; +import org.jooq.Index; +import org.jooq.InverseForeignKey; +import org.jooq.JSONB; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class AuditLog extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of audit_log + */ + public static final AuditLog AUDIT_LOG = new AuditLog(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return AuditLogRecord.class; + } + + /** + * The column audit_log.id. + */ + public final TableField ID = createField(DSL.name("id"), SQLDataType.INTEGER.identity(true), this, ""); + + /** + * The column audit_log.user_id. + */ + public final TableField USER_ID = createField(DSL.name("user_id"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column audit_log.action_type. + */ + public final TableField ACTION_TYPE = createField(DSL.name("action_type"), SQLDataType.CLOB.nullable(false), this, ""); + + /** + * The column audit_log.data. + */ + public final TableField DATA = createField(DSL.name("data"), SQLDataType.JSONB, this, ""); + + /** + * The column audit_log.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.LOCALDATETIME(0).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.LOCALDATETIME)), this, ""); + + private AuditLog(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private AuditLog(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased audit_log table reference + */ + public AuditLog(String alias) { + this(DSL.name(alias), AUDIT_LOG); + } + + /** + * Create an aliased audit_log table reference + */ + public AuditLog(Name alias) { + this(alias, AUDIT_LOG); + } + + /** + * Create a audit_log table reference + */ + public AuditLog() { + this(DSL.name("audit_log"), null); + } + + public AuditLog(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, AUDIT_LOG); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class AuditLogPath extends AuditLog implements Path { + + private static final long serialVersionUID = 1L; + public AuditLogPath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private AuditLogPath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public AuditLogPath as(String alias) { + return new AuditLogPath(DSL.name(alias), this); + } + + @Override + public AuditLogPath as(Name alias) { + return new AuditLogPath(alias, this); + } + + @Override + public AuditLogPath as(Table alias) { + return new AuditLogPath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : DefaultSchema.DEFAULT_SCHEMA; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.AUDIT_LOG_BY_USER_ID); + } + + @Override + public Identity getIdentity() { + return (Identity) super.getIdentity(); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.AUDIT_LOG__PK_AUDIT_LOG; + } + + @Override + public List> getReferences() { + return Arrays.asList(Keys.AUDIT_LOG__FK_AUDIT_LOG_PK_DISCORD_USERS); + } + + private transient DiscordUsersPath _discordUsers; + + /** + * Get the implicit join path to the discord_users table. + */ + public DiscordUsersPath discordUsers() { + if (_discordUsers == null) + _discordUsers = new DiscordUsersPath(this, Keys.AUDIT_LOG__FK_AUDIT_LOG_PK_DISCORD_USERS, null); + + return _discordUsers; + } + + @Override + public AuditLog as(String alias) { + return new AuditLog(DSL.name(alias), this); + } + + @Override + public AuditLog as(Name alias) { + return new AuditLog(alias, this); + } + + @Override + public AuditLog as(Table alias) { + return new AuditLog(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public AuditLog rename(String name) { + return new AuditLog(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public AuditLog rename(Name name) { + return new AuditLog(name, null); + } + + /** + * Rename this table + */ + @Override + public AuditLog rename(Table name) { + return new AuditLog(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public AuditLog where(Condition condition) { + return new AuditLog(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public AuditLog where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public AuditLog where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public AuditLog where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public AuditLog where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public AuditLog where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public AuditLog where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public AuditLog where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public AuditLog whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public AuditLog whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/DiscordUsers.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/DiscordUsers.java new file mode 100644 index 0000000..7c65f81 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/DiscordUsers.java @@ -0,0 +1,305 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables; + + +import java.time.LocalDateTime; +import java.util.Collection; + +import net.forgecraft.services.ember.db.schema.DefaultSchema; +import net.forgecraft.services.ember.db.schema.Keys; +import net.forgecraft.services.ember.db.schema.tables.AuditLog.AuditLogPath; +import net.forgecraft.services.ember.db.schema.tables.ModFiles.ModFilesPath; +import net.forgecraft.services.ember.db.schema.tables.ModOwners.ModOwnersPath; +import net.forgecraft.services.ember.db.schema.tables.records.DiscordUsersRecord; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.InverseForeignKey; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class DiscordUsers extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of discord_users + */ + public static final DiscordUsers DISCORD_USERS = new DiscordUsers(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return DiscordUsersRecord.class; + } + + /** + * The column discord_users.snowflake. + */ + public final TableField SNOWFLAKE = createField(DSL.name("snowflake"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column discord_users.display_name. + */ + public final TableField DISPLAY_NAME = createField(DSL.name("display_name"), SQLDataType.CLOB, this, ""); + + /** + * The column discord_users.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.LOCALDATETIME(0).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.LOCALDATETIME)), this, ""); + + private DiscordUsers(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private DiscordUsers(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased discord_users table reference + */ + public DiscordUsers(String alias) { + this(DSL.name(alias), DISCORD_USERS); + } + + /** + * Create an aliased discord_users table reference + */ + public DiscordUsers(Name alias) { + this(alias, DISCORD_USERS); + } + + /** + * Create a discord_users table reference + */ + public DiscordUsers() { + this(DSL.name("discord_users"), null); + } + + public DiscordUsers(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, DISCORD_USERS); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class DiscordUsersPath extends DiscordUsers implements Path { + + private static final long serialVersionUID = 1L; + public DiscordUsersPath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private DiscordUsersPath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public DiscordUsersPath as(String alias) { + return new DiscordUsersPath(DSL.name(alias), this); + } + + @Override + public DiscordUsersPath as(Name alias) { + return new DiscordUsersPath(alias, this); + } + + @Override + public DiscordUsersPath as(Table alias) { + return new DiscordUsersPath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : DefaultSchema.DEFAULT_SCHEMA; + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.DISCORD_USERS__PK_DISCORD_USERS; + } + + private transient AuditLogPath _auditLog; + + /** + * Get the implicit to-many join path to the audit_log table + */ + public AuditLogPath auditLog() { + if (_auditLog == null) + _auditLog = new AuditLogPath(this, null, Keys.AUDIT_LOG__FK_AUDIT_LOG_PK_DISCORD_USERS.getInverseKey()); + + return _auditLog; + } + + private transient ModFilesPath _modFiles; + + /** + * Get the implicit to-many join path to the mod_files table + */ + public ModFilesPath modFiles() { + if (_modFiles == null) + _modFiles = new ModFilesPath(this, null, Keys.MOD_FILES__FK_MOD_FILES_PK_DISCORD_USERS.getInverseKey()); + + return _modFiles; + } + + private transient ModOwnersPath _modOwners; + + /** + * Get the implicit to-many join path to the mod_owners table + */ + public ModOwnersPath modOwners() { + if (_modOwners == null) + _modOwners = new ModOwnersPath(this, null, Keys.MOD_OWNERS__FK_MOD_OWNERS_PK_DISCORD_USERS.getInverseKey()); + + return _modOwners; + } + + @Override + public DiscordUsers as(String alias) { + return new DiscordUsers(DSL.name(alias), this); + } + + @Override + public DiscordUsers as(Name alias) { + return new DiscordUsers(alias, this); + } + + @Override + public DiscordUsers as(Table alias) { + return new DiscordUsers(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public DiscordUsers rename(String name) { + return new DiscordUsers(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public DiscordUsers rename(Name name) { + return new DiscordUsers(name, null); + } + + /** + * Rename this table + */ + @Override + public DiscordUsers rename(Table name) { + return new DiscordUsers(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public DiscordUsers where(Condition condition) { + return new DiscordUsers(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public DiscordUsers where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public DiscordUsers where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public DiscordUsers where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public DiscordUsers where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public DiscordUsers where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public DiscordUsers where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public DiscordUsers where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public DiscordUsers whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public DiscordUsers whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ModFiles.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ModFiles.java new file mode 100644 index 0000000..f972e77 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ModFiles.java @@ -0,0 +1,345 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables; + + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import net.forgecraft.services.ember.db.schema.DefaultSchema; +import net.forgecraft.services.ember.db.schema.Indexes; +import net.forgecraft.services.ember.db.schema.Keys; +import net.forgecraft.services.ember.db.schema.tables.ApprovalQueue.ApprovalQueuePath; +import net.forgecraft.services.ember.db.schema.tables.DiscordUsers.DiscordUsersPath; +import net.forgecraft.services.ember.db.schema.tables.Mods.ModsPath; +import net.forgecraft.services.ember.db.schema.tables.records.ModFilesRecord; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.Index; +import org.jooq.InverseForeignKey; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ModFiles extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of mod_files + */ + public static final ModFiles MOD_FILES = new ModFiles(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return ModFilesRecord.class; + } + + /** + * The column mod_files.id. + */ + public final TableField ID = createField(DSL.name("id"), SQLDataType.INTEGER, this, ""); + + /** + * The column mod_files.mod_id. + */ + public final TableField MOD_ID = createField(DSL.name("mod_id"), SQLDataType.CLOB.nullable(false), this, ""); + + /** + * The column mod_files.uploader_id. + */ + public final TableField UPLOADER_ID = createField(DSL.name("uploader_id"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column mod_files.mod_version. + */ + public final TableField MOD_VERSION = createField(DSL.name("mod_version"), SQLDataType.CLOB.nullable(false), this, ""); + + /** + * The column mod_files.active. + */ + public final TableField ACTIVE = createField(DSL.name("active"), SQLDataType.BOOLEAN.defaultValue(DSL.field(DSL.raw("FALSE"), SQLDataType.BOOLEAN)), this, ""); + + /** + * The column mod_files.file_name. + */ + public final TableField FILE_NAME = createField(DSL.name("file_name"), SQLDataType.CLOB.nullable(false), this, ""); + + /** + * The column mod_files.sha_512. + */ + public final TableField SHA_512 = createField(DSL.name("sha_512"), SQLDataType.BLOB.nullable(false), this, ""); + + /** + * The column mod_files.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.LOCALDATETIME(0).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.LOCALDATETIME)), this, ""); + + private ModFiles(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private ModFiles(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased mod_files table reference + */ + public ModFiles(String alias) { + this(DSL.name(alias), MOD_FILES); + } + + /** + * Create an aliased mod_files table reference + */ + public ModFiles(Name alias) { + this(alias, MOD_FILES); + } + + /** + * Create a mod_files table reference + */ + public ModFiles() { + this(DSL.name("mod_files"), null); + } + + public ModFiles(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, MOD_FILES); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class ModFilesPath extends ModFiles implements Path { + + private static final long serialVersionUID = 1L; + public ModFilesPath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private ModFilesPath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public ModFilesPath as(String alias) { + return new ModFilesPath(DSL.name(alias), this); + } + + @Override + public ModFilesPath as(Name alias) { + return new ModFilesPath(alias, this); + } + + @Override + public ModFilesPath as(Table alias) { + return new ModFilesPath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : DefaultSchema.DEFAULT_SCHEMA; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.MOD_FILES_BY_ACTIVE, Indexes.MOD_FILES_BY_MOD_ID, Indexes.MOD_FILES_BY_MODID_AND_SHA512, Indexes.MOD_FILES_BY_UPLOADER_ID); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.MOD_FILES__PK_MOD_FILES; + } + + @Override + public List> getReferences() { + return Arrays.asList(Keys.MOD_FILES__FK_MOD_FILES_PK_MODS, Keys.MOD_FILES__FK_MOD_FILES_PK_DISCORD_USERS); + } + + private transient ModsPath _mods; + + /** + * Get the implicit join path to the mods table. + */ + public ModsPath mods() { + if (_mods == null) + _mods = new ModsPath(this, Keys.MOD_FILES__FK_MOD_FILES_PK_MODS, null); + + return _mods; + } + + private transient DiscordUsersPath _discordUsers; + + /** + * Get the implicit join path to the discord_users table. + */ + public DiscordUsersPath discordUsers() { + if (_discordUsers == null) + _discordUsers = new DiscordUsersPath(this, Keys.MOD_FILES__FK_MOD_FILES_PK_DISCORD_USERS, null); + + return _discordUsers; + } + + private transient ApprovalQueuePath _approvalQueue; + + /** + * Get the implicit to-many join path to the approval_queue + * table + */ + public ApprovalQueuePath approvalQueue() { + if (_approvalQueue == null) + _approvalQueue = new ApprovalQueuePath(this, null, Keys.APPROVAL_QUEUE__FK_APPROVAL_QUEUE_PK_MOD_FILES.getInverseKey()); + + return _approvalQueue; + } + + @Override + public ModFiles as(String alias) { + return new ModFiles(DSL.name(alias), this); + } + + @Override + public ModFiles as(Name alias) { + return new ModFiles(alias, this); + } + + @Override + public ModFiles as(Table alias) { + return new ModFiles(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public ModFiles rename(String name) { + return new ModFiles(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public ModFiles rename(Name name) { + return new ModFiles(name, null); + } + + /** + * Rename this table + */ + @Override + public ModFiles rename(Table name) { + return new ModFiles(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModFiles where(Condition condition) { + return new ModFiles(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModFiles where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModFiles where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModFiles where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModFiles where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModFiles where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModFiles where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModFiles where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModFiles whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModFiles whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ModOwners.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ModOwners.java new file mode 100644 index 0000000..fcb0eaf --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/ModOwners.java @@ -0,0 +1,311 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables; + + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import net.forgecraft.services.ember.db.schema.DefaultSchema; +import net.forgecraft.services.ember.db.schema.Indexes; +import net.forgecraft.services.ember.db.schema.Keys; +import net.forgecraft.services.ember.db.schema.tables.DiscordUsers.DiscordUsersPath; +import net.forgecraft.services.ember.db.schema.tables.Mods.ModsPath; +import net.forgecraft.services.ember.db.schema.tables.records.ModOwnersRecord; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.Index; +import org.jooq.InverseForeignKey; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ModOwners extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of mod_owners + */ + public static final ModOwners MOD_OWNERS = new ModOwners(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return ModOwnersRecord.class; + } + + /** + * The column mod_owners.id. + */ + public final TableField ID = createField(DSL.name("id"), SQLDataType.INTEGER, this, ""); + + /** + * The column mod_owners.mod_id. + */ + public final TableField MOD_ID = createField(DSL.name("mod_id"), SQLDataType.CLOB.nullable(false), this, ""); + + /** + * The column mod_owners.user_id. + */ + public final TableField USER_ID = createField(DSL.name("user_id"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column mod_owners.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.LOCALDATETIME(0).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.LOCALDATETIME)), this, ""); + + private ModOwners(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private ModOwners(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased mod_owners table reference + */ + public ModOwners(String alias) { + this(DSL.name(alias), MOD_OWNERS); + } + + /** + * Create an aliased mod_owners table reference + */ + public ModOwners(Name alias) { + this(alias, MOD_OWNERS); + } + + /** + * Create a mod_owners table reference + */ + public ModOwners() { + this(DSL.name("mod_owners"), null); + } + + public ModOwners(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, MOD_OWNERS); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class ModOwnersPath extends ModOwners implements Path { + + private static final long serialVersionUID = 1L; + public ModOwnersPath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private ModOwnersPath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public ModOwnersPath as(String alias) { + return new ModOwnersPath(DSL.name(alias), this); + } + + @Override + public ModOwnersPath as(Name alias) { + return new ModOwnersPath(alias, this); + } + + @Override + public ModOwnersPath as(Table alias) { + return new ModOwnersPath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : DefaultSchema.DEFAULT_SCHEMA; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.MOD_OWNERS_BY_DISCORD_USER, Indexes.MOD_OWNERS_BY_MOD_ID); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.MOD_OWNERS__PK_MOD_OWNERS; + } + + @Override + public List> getReferences() { + return Arrays.asList(Keys.MOD_OWNERS__FK_MOD_OWNERS_PK_MODS, Keys.MOD_OWNERS__FK_MOD_OWNERS_PK_DISCORD_USERS); + } + + private transient ModsPath _mods; + + /** + * Get the implicit join path to the mods table. + */ + public ModsPath mods() { + if (_mods == null) + _mods = new ModsPath(this, Keys.MOD_OWNERS__FK_MOD_OWNERS_PK_MODS, null); + + return _mods; + } + + private transient DiscordUsersPath _discordUsers; + + /** + * Get the implicit join path to the discord_users table. + */ + public DiscordUsersPath discordUsers() { + if (_discordUsers == null) + _discordUsers = new DiscordUsersPath(this, Keys.MOD_OWNERS__FK_MOD_OWNERS_PK_DISCORD_USERS, null); + + return _discordUsers; + } + + @Override + public ModOwners as(String alias) { + return new ModOwners(DSL.name(alias), this); + } + + @Override + public ModOwners as(Name alias) { + return new ModOwners(alias, this); + } + + @Override + public ModOwners as(Table alias) { + return new ModOwners(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public ModOwners rename(String name) { + return new ModOwners(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public ModOwners rename(Name name) { + return new ModOwners(name, null); + } + + /** + * Rename this table + */ + @Override + public ModOwners rename(Table name) { + return new ModOwners(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModOwners where(Condition condition) { + return new ModOwners(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModOwners where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModOwners where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModOwners where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModOwners where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModOwners where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModOwners where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public ModOwners where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModOwners whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public ModOwners whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/Mods.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/Mods.java new file mode 100644 index 0000000..3cf0cc1 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/Mods.java @@ -0,0 +1,297 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables; + + +import java.time.LocalDateTime; +import java.util.Collection; + +import net.forgecraft.services.ember.db.schema.DefaultSchema; +import net.forgecraft.services.ember.db.schema.Keys; +import net.forgecraft.services.ember.db.schema.tables.ModFiles.ModFilesPath; +import net.forgecraft.services.ember.db.schema.tables.ModOwners.ModOwnersPath; +import net.forgecraft.services.ember.db.schema.tables.records.ModsRecord; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.InverseForeignKey; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Mods extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of mods + */ + public static final Mods MODS = new Mods(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return ModsRecord.class; + } + + /** + * The column mods.id. + */ + public final TableField ID = createField(DSL.name("id"), SQLDataType.CLOB.nullable(false), this, ""); + + /** + * The column mods.project_url. + */ + public final TableField PROJECT_URL = createField(DSL.name("project_url"), SQLDataType.CLOB, this, ""); + + /** + * The column mods.issues_url. + */ + public final TableField ISSUES_URL = createField(DSL.name("issues_url"), SQLDataType.CLOB, this, ""); + + /** + * The column mods.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.LOCALDATETIME(0).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.LOCALDATETIME)), this, ""); + + private Mods(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private Mods(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased mods table reference + */ + public Mods(String alias) { + this(DSL.name(alias), MODS); + } + + /** + * Create an aliased mods table reference + */ + public Mods(Name alias) { + this(alias, MODS); + } + + /** + * Create a mods table reference + */ + public Mods() { + this(DSL.name("mods"), null); + } + + public Mods(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, MODS); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class ModsPath extends Mods implements Path { + + private static final long serialVersionUID = 1L; + public ModsPath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private ModsPath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public ModsPath as(String alias) { + return new ModsPath(DSL.name(alias), this); + } + + @Override + public ModsPath as(Name alias) { + return new ModsPath(alias, this); + } + + @Override + public ModsPath as(Table alias) { + return new ModsPath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : DefaultSchema.DEFAULT_SCHEMA; + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.MODS__PK_MODS; + } + + private transient ModFilesPath _modFiles; + + /** + * Get the implicit to-many join path to the mod_files table + */ + public ModFilesPath modFiles() { + if (_modFiles == null) + _modFiles = new ModFilesPath(this, null, Keys.MOD_FILES__FK_MOD_FILES_PK_MODS.getInverseKey()); + + return _modFiles; + } + + private transient ModOwnersPath _modOwners; + + /** + * Get the implicit to-many join path to the mod_owners table + */ + public ModOwnersPath modOwners() { + if (_modOwners == null) + _modOwners = new ModOwnersPath(this, null, Keys.MOD_OWNERS__FK_MOD_OWNERS_PK_MODS.getInverseKey()); + + return _modOwners; + } + + @Override + public Mods as(String alias) { + return new Mods(DSL.name(alias), this); + } + + @Override + public Mods as(Name alias) { + return new Mods(alias, this); + } + + @Override + public Mods as(Table alias) { + return new Mods(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public Mods rename(String name) { + return new Mods(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public Mods rename(Name name) { + return new Mods(name, null); + } + + /** + * Rename this table + */ + @Override + public Mods rename(Table name) { + return new Mods(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public Mods where(Condition condition) { + return new Mods(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public Mods where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public Mods where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public Mods where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public Mods where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public Mods where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public Mods where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public Mods where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public Mods whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public Mods whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ApprovalQueueRecord.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ApprovalQueueRecord.java new file mode 100644 index 0000000..ee47eda --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ApprovalQueueRecord.java @@ -0,0 +1,96 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables.records; + + +import java.time.LocalDateTime; + +import net.forgecraft.services.ember.db.schema.tables.ApprovalQueue; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ApprovalQueueRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for approval_queue.message_id. + */ + public void setMessageId(Long value) { + set(0, value); + } + + /** + * Getter for approval_queue.message_id. + */ + public Long getMessageId() { + return (Long) get(0); + } + + /** + * Setter for approval_queue.mod_file_id. + */ + public void setModFileId(Integer value) { + set(1, value); + } + + /** + * Getter for approval_queue.mod_file_id. + */ + public Integer getModFileId() { + return (Integer) get(1); + } + + /** + * Setter for approval_queue.created_at. + */ + public void setCreatedAt(LocalDateTime value) { + set(2, value); + } + + /** + * Getter for approval_queue.created_at. + */ + public LocalDateTime getCreatedAt() { + return (LocalDateTime) get(2); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached ApprovalQueueRecord + */ + public ApprovalQueueRecord() { + super(ApprovalQueue.APPROVAL_QUEUE); + } + + /** + * Create a detached, initialised ApprovalQueueRecord + */ + public ApprovalQueueRecord(Long messageId, Integer modFileId, LocalDateTime createdAt) { + super(ApprovalQueue.APPROVAL_QUEUE); + + setMessageId(messageId); + setModFileId(modFileId); + setCreatedAt(createdAt); + resetChangedOnNotNull(); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/AuditLogRecord.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/AuditLogRecord.java new file mode 100644 index 0000000..f418fa5 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/AuditLogRecord.java @@ -0,0 +1,127 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables.records; + + +import java.time.LocalDateTime; + +import net.forgecraft.services.ember.db.schema.tables.AuditLog; + +import org.jooq.JSONB; +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class AuditLogRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for audit_log.id. + */ + public void setId(Integer value) { + set(0, value); + } + + /** + * Getter for audit_log.id. + */ + public Integer getId() { + return (Integer) get(0); + } + + /** + * Setter for audit_log.user_id. + */ + public void setUserId(Long value) { + set(1, value); + } + + /** + * Getter for audit_log.user_id. + */ + public Long getUserId() { + return (Long) get(1); + } + + /** + * Setter for audit_log.action_type. + */ + public void setActionType(String value) { + set(2, value); + } + + /** + * Getter for audit_log.action_type. + */ + public String getActionType() { + return (String) get(2); + } + + /** + * Setter for audit_log.data. + */ + public void setData(JSONB value) { + set(3, value); + } + + /** + * Getter for audit_log.data. + */ + public JSONB getData() { + return (JSONB) get(3); + } + + /** + * Setter for audit_log.created_at. + */ + public void setCreatedAt(LocalDateTime value) { + set(4, value); + } + + /** + * Getter for audit_log.created_at. + */ + public LocalDateTime getCreatedAt() { + return (LocalDateTime) get(4); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached AuditLogRecord + */ + public AuditLogRecord() { + super(AuditLog.AUDIT_LOG); + } + + /** + * Create a detached, initialised AuditLogRecord + */ + public AuditLogRecord(Integer id, Long userId, String actionType, JSONB data, LocalDateTime createdAt) { + super(AuditLog.AUDIT_LOG); + + setId(id); + setUserId(userId); + setActionType(actionType); + setData(data); + setCreatedAt(createdAt); + resetChangedOnNotNull(); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/DiscordUsersRecord.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/DiscordUsersRecord.java new file mode 100644 index 0000000..3acb335 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/DiscordUsersRecord.java @@ -0,0 +1,96 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables.records; + + +import java.time.LocalDateTime; + +import net.forgecraft.services.ember.db.schema.tables.DiscordUsers; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class DiscordUsersRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for discord_users.snowflake. + */ + public void setSnowflake(Long value) { + set(0, value); + } + + /** + * Getter for discord_users.snowflake. + */ + public Long getSnowflake() { + return (Long) get(0); + } + + /** + * Setter for discord_users.display_name. + */ + public void setDisplayName(String value) { + set(1, value); + } + + /** + * Getter for discord_users.display_name. + */ + public String getDisplayName() { + return (String) get(1); + } + + /** + * Setter for discord_users.created_at. + */ + public void setCreatedAt(LocalDateTime value) { + set(2, value); + } + + /** + * Getter for discord_users.created_at. + */ + public LocalDateTime getCreatedAt() { + return (LocalDateTime) get(2); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached DiscordUsersRecord + */ + public DiscordUsersRecord() { + super(DiscordUsers.DISCORD_USERS); + } + + /** + * Create a detached, initialised DiscordUsersRecord + */ + public DiscordUsersRecord(Long snowflake, String displayName, LocalDateTime createdAt) { + super(DiscordUsers.DISCORD_USERS); + + setSnowflake(snowflake); + setDisplayName(displayName); + setCreatedAt(createdAt); + resetChangedOnNotNull(); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModFilesRecord.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModFilesRecord.java new file mode 100644 index 0000000..e490ec7 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModFilesRecord.java @@ -0,0 +1,171 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables.records; + + +import java.time.LocalDateTime; + +import net.forgecraft.services.ember.db.schema.tables.ModFiles; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ModFilesRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for mod_files.id. + */ + public void setId(Integer value) { + set(0, value); + } + + /** + * Getter for mod_files.id. + */ + public Integer getId() { + return (Integer) get(0); + } + + /** + * Setter for mod_files.mod_id. + */ + public void setModId(String value) { + set(1, value); + } + + /** + * Getter for mod_files.mod_id. + */ + public String getModId() { + return (String) get(1); + } + + /** + * Setter for mod_files.uploader_id. + */ + public void setUploaderId(Long value) { + set(2, value); + } + + /** + * Getter for mod_files.uploader_id. + */ + public Long getUploaderId() { + return (Long) get(2); + } + + /** + * Setter for mod_files.mod_version. + */ + public void setModVersion(String value) { + set(3, value); + } + + /** + * Getter for mod_files.mod_version. + */ + public String getModVersion() { + return (String) get(3); + } + + /** + * Setter for mod_files.active. + */ + public void setActive(Boolean value) { + set(4, value); + } + + /** + * Getter for mod_files.active. + */ + public Boolean getActive() { + return (Boolean) get(4); + } + + /** + * Setter for mod_files.file_name. + */ + public void setFileName(String value) { + set(5, value); + } + + /** + * Getter for mod_files.file_name. + */ + public String getFileName() { + return (String) get(5); + } + + /** + * Setter for mod_files.sha_512. + */ + public void setSha_512(byte[] value) { + set(6, value); + } + + /** + * Getter for mod_files.sha_512. + */ + public byte[] getSha_512() { + return (byte[]) get(6); + } + + /** + * Setter for mod_files.created_at. + */ + public void setCreatedAt(LocalDateTime value) { + set(7, value); + } + + /** + * Getter for mod_files.created_at. + */ + public LocalDateTime getCreatedAt() { + return (LocalDateTime) get(7); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached ModFilesRecord + */ + public ModFilesRecord() { + super(ModFiles.MOD_FILES); + } + + /** + * Create a detached, initialised ModFilesRecord + */ + public ModFilesRecord(Integer id, String modId, Long uploaderId, String modVersion, Boolean active, String fileName, byte[] sha_512, LocalDateTime createdAt) { + super(ModFiles.MOD_FILES); + + setId(id); + setModId(modId); + setUploaderId(uploaderId); + setModVersion(modVersion); + setActive(active); + setFileName(fileName); + setSha_512(sha_512); + setCreatedAt(createdAt); + resetChangedOnNotNull(); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModOwnersRecord.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModOwnersRecord.java new file mode 100644 index 0000000..0b22a2f --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModOwnersRecord.java @@ -0,0 +1,111 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables.records; + + +import java.time.LocalDateTime; + +import net.forgecraft.services.ember.db.schema.tables.ModOwners; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ModOwnersRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for mod_owners.id. + */ + public void setId(Integer value) { + set(0, value); + } + + /** + * Getter for mod_owners.id. + */ + public Integer getId() { + return (Integer) get(0); + } + + /** + * Setter for mod_owners.mod_id. + */ + public void setModId(String value) { + set(1, value); + } + + /** + * Getter for mod_owners.mod_id. + */ + public String getModId() { + return (String) get(1); + } + + /** + * Setter for mod_owners.user_id. + */ + public void setUserId(Long value) { + set(2, value); + } + + /** + * Getter for mod_owners.user_id. + */ + public Long getUserId() { + return (Long) get(2); + } + + /** + * Setter for mod_owners.created_at. + */ + public void setCreatedAt(LocalDateTime value) { + set(3, value); + } + + /** + * Getter for mod_owners.created_at. + */ + public LocalDateTime getCreatedAt() { + return (LocalDateTime) get(3); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached ModOwnersRecord + */ + public ModOwnersRecord() { + super(ModOwners.MOD_OWNERS); + } + + /** + * Create a detached, initialised ModOwnersRecord + */ + public ModOwnersRecord(Integer id, String modId, Long userId, LocalDateTime createdAt) { + super(ModOwners.MOD_OWNERS); + + setId(id); + setModId(modId); + setUserId(userId); + setCreatedAt(createdAt); + resetChangedOnNotNull(); + } +} diff --git a/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModsRecord.java b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModsRecord.java new file mode 100644 index 0000000..5539e55 --- /dev/null +++ b/src/generated/java/net/forgecraft/services/ember/db/schema/tables/records/ModsRecord.java @@ -0,0 +1,111 @@ +/* + * This file is generated by jOOQ. + */ +package net.forgecraft.services.ember.db.schema.tables.records; + + +import java.time.LocalDateTime; + +import net.forgecraft.services.ember.db.schema.tables.Mods; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ModsRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for mods.id. + */ + public void setId(String value) { + set(0, value); + } + + /** + * Getter for mods.id. + */ + public String getId() { + return (String) get(0); + } + + /** + * Setter for mods.project_url. + */ + public void setProjectUrl(String value) { + set(1, value); + } + + /** + * Getter for mods.project_url. + */ + public String getProjectUrl() { + return (String) get(1); + } + + /** + * Setter for mods.issues_url. + */ + public void setIssuesUrl(String value) { + set(2, value); + } + + /** + * Getter for mods.issues_url. + */ + public String getIssuesUrl() { + return (String) get(2); + } + + /** + * Setter for mods.created_at. + */ + public void setCreatedAt(LocalDateTime value) { + set(3, value); + } + + /** + * Getter for mods.created_at. + */ + public LocalDateTime getCreatedAt() { + return (LocalDateTime) get(3); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached ModsRecord + */ + public ModsRecord() { + super(Mods.MODS); + } + + /** + * Create a detached, initialised ModsRecord + */ + public ModsRecord(String id, String projectUrl, String issuesUrl, LocalDateTime createdAt) { + super(Mods.MODS); + + setId(id); + setProjectUrl(projectUrl); + setIssuesUrl(issuesUrl); + setCreatedAt(createdAt); + resetChangedOnNotNull(); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/Main.java b/src/main/java/net/forgecraft/services/ember/Main.java index bd273c1..1f47a65 100644 --- a/src/main/java/net/forgecraft/services/ember/Main.java +++ b/src/main/java/net/forgecraft/services/ember/Main.java @@ -2,32 +2,64 @@ import net.forgecraft.services.ember.app.Services; +import net.forgecraft.services.ember.bot.listener.ModApprovalListener; +import net.forgecraft.services.ember.bot.listener.ModUploadListener; +import net.forgecraft.services.ember.db.DatabaseManager; import org.javacord.api.DiscordApi; import org.javacord.api.DiscordApiBuilder; import org.javacord.api.entity.intent.Intent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import picocli.CommandLine; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; public final class Main { private static final Logger LOGGER = LoggerFactory.getLogger(Main.class); - private static Main INSTANCE; + @Deprecated + public static volatile Main INSTANCE; private final Services services; private final DiscordApi discordApi; public static void main(String[] args) { LOGGER.info("Starting application..."); - INSTANCE = new Main(args); + + var opts = CommandLine.populateSpec(Main.Cli.class, args); + if (opts.helpRequested) { + CommandLine.usage(opts, System.out); + return; + } + + INSTANCE = new Main(opts); } // Real application start - public Main(String[] args) { - this.services = new Services(args); + public Main(Cli opts) { + this.services = new Services(opts.configPath); + + try { + var dbPath = services.getConfig().getGeneral().databasePath(); + Files.createDirectories(dbPath.getParent()); + } catch (IOException e) { + throw new RuntimeException("Failed to create database directory", e); + } + + try (var ctx = services.getDbConnection()) { + DatabaseManager.bootstrapDatabase(ctx); + } this.discordApi = new DiscordApiBuilder() .setToken(services.getConfig().getDiscord().token()) - // Pulled over from the old bot, not sure if all of these are needed - .addIntents(Intent.GUILDS, Intent.GUILD_MEMBERS, Intent.GUILD_MESSAGES, Intent.GUILD_MESSAGE_REACTIONS, Intent.DIRECT_MESSAGES, Intent.DIRECT_MESSAGE_REACTIONS) + .addIntents( + Intent.GUILDS, + Intent.GUILD_MESSAGES, // general message events + Intent.MESSAGE_CONTENT, // read message contents for mod uploads + Intent.GUILD_MESSAGE_REACTIONS, // read reactions for mod approvals + Intent.GUILD_MEMBERS // read member list for admin role checks + ) .login() .join(); @@ -36,6 +68,9 @@ public Main(String[] args) { event.getChannel().sendMessage("Pong!"); } }); + + this.discordApi.addMessageCreateListener(new ModUploadListener(services.getConfig())); + this.discordApi.addReactionAddListener(new ModApprovalListener(services.getConfig())); } public Services services() { @@ -45,4 +80,13 @@ public Services services() { public DiscordApi discordApi() { return discordApi; } + + public static class Cli { + + @CommandLine.Option(names = {"-c", "--config"}, required = true, description = "Path to the configuration file") + public Path configPath; + + @CommandLine.Option(names = {"-h", "-?", "--help"}, description = "Prints this help message and exits", usageHelp = true) + public boolean helpRequested = false; + } } diff --git a/src/main/java/net/forgecraft/services/ember/app/Config.java b/src/main/java/net/forgecraft/services/ember/app/Config.java deleted file mode 100644 index ae28de1..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/Config.java +++ /dev/null @@ -1,86 +0,0 @@ -package net.forgecraft.services.ember.app; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -public class Config { - private DiscordConfig discord; - - public Config(String configPath) { - load(Path.of(configPath)); - } - - // For jackson - public Config() {} - - public void load(Path path) { - var configRaw = this.loadFromPath(path); - - var mapper = new ObjectMapper(); - try { - var appConfig = mapper.readValue(configRaw, Config.class); - - this.discord = appConfig.discord; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private String loadFromPath(Path path) throws RuntimeException { - if (Files.notExists(path)) { - // Create it - var parent = path.getParent(); - if (parent != null) { - try { - Files.createDirectories(parent); - } catch (Exception e) { - throw new RuntimeException("Failed to create config file", e); - } - } - - // Write the default - var defaultConfig = createDefaultConfig(); - var mapper = new ObjectMapper(); - try { - String defaultConfigString = mapper.writeValueAsString(defaultConfig); - Files.writeString(path, defaultConfigString); - return defaultConfigString; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - // We exist so just read it - try { - return Files.readString(path); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private static Config createDefaultConfig() { - var config = new Config(); - config.discord = new DiscordConfig("YOUR_DISCORD", -1, new long[] {}); - - return config; - } - - public DiscordConfig getDiscord() { - return discord; - } - - public record DiscordConfig( - String token, - long guild, - long[] adminRoles - ) {} - - public record ServerAutomationsConfig( - String serverPath, - String slug, - long serverUpdateChannel - ) {} -} diff --git a/src/main/java/net/forgecraft/services/ember/app/Services.java b/src/main/java/net/forgecraft/services/ember/app/Services.java index 0b5263a..50111ab 100644 --- a/src/main/java/net/forgecraft/services/ember/app/Services.java +++ b/src/main/java/net/forgecraft/services/ember/app/Services.java @@ -1,20 +1,30 @@ package net.forgecraft.services.ember.app; -import net.forgecraft.services.ember.helpers.ArgsParser; +import net.forgecraft.services.ember.app.config.Config; +import org.jooq.CloseableDSLContext; +import org.jooq.impl.DSL; + +import java.nio.file.Path; +import java.util.function.Supplier; /** * Basic services class to use as dependency injection */ public class Services { private final Config config; + private final Supplier database; - public Services(String[] args) { - var appArgs = ArgsParser.parse(args); - - this.config = new Config(appArgs.getOrThrow("config")); + public Services(Path configPath) { + this.config = Config.load(configPath); + var dbUrl = "jdbc:sqlite:%s".formatted(config.getGeneral().databasePath()); + this.database = () -> DSL.using(dbUrl); } public Config getConfig() { return config; } + + public CloseableDSLContext getDbConnection() { + return database.get(); + } } diff --git a/src/main/java/net/forgecraft/services/ember/app/config/Config.java b/src/main/java/net/forgecraft/services/ember/app/config/Config.java new file mode 100644 index 0000000..25a8e19 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/config/Config.java @@ -0,0 +1,70 @@ +package net.forgecraft.services.ember.app.config; + +import net.forgecraft.services.ember.util.Util; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class Config { + + private final GeneralConfig general = GeneralConfig.create(); + private final DiscordConfig discord = DiscordConfig.create(); + private final CurseforgeConfig curseforge = CurseforgeConfig.create(); + private final ModrinthConfig modrinth = ModrinthConfig.create(); + private final List minecraftServers = List.of(MinecraftServerConfig.create()); + + + public static Config load(Path path) { + try (var stream = loadFromPath(path)) { + return Util.JACKSON_MAPPER.readValue(stream, Config.class); + } catch (IOException e) { + throw new RuntimeException("Unable to read config file", e); + } + } + + private static InputStream loadFromPath(Path path) throws IOException { + if (Files.notExists(path)) { + // Create it + var parent = path.getParent(); + if (parent != null) { + try { + Files.createDirectories(parent); + } catch (Exception e) { + throw new RuntimeException("Failed to create config file", e); + } + } + + // Write the default + try (var writer = Files.newBufferedWriter(path)) { + Util.JACKSON_MAPPER.writeValue(writer, new Config()); + } + } + + // We exist so just read it + return Files.newInputStream(path); + } + + public GeneralConfig getGeneral() { + return general; + } + + public DiscordConfig getDiscord() { + return discord; + } + + public CurseforgeConfig getCurseforge() { + return curseforge; + } + + public ModrinthConfig getModrinth() { + return modrinth; + } + + public List getMinecraftServers() { + return minecraftServers; + } + +} diff --git a/src/main/java/net/forgecraft/services/ember/app/config/CurseforgeConfig.java b/src/main/java/net/forgecraft/services/ember/app/config/CurseforgeConfig.java new file mode 100644 index 0000000..1853824 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/config/CurseforgeConfig.java @@ -0,0 +1,11 @@ +package net.forgecraft.services.ember.app.config; + +import java.util.Optional; + +public record CurseforgeConfig( + Optional accessToken +) { + public static CurseforgeConfig create() { + return new CurseforgeConfig(Optional.empty()); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/config/DiscordConfig.java b/src/main/java/net/forgecraft/services/ember/app/config/DiscordConfig.java new file mode 100644 index 0000000..480c212 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/config/DiscordConfig.java @@ -0,0 +1,11 @@ +package net.forgecraft.services.ember.app.config; + +public record DiscordConfig( + String token, + long guild, + long[] adminRoles +) { + public static DiscordConfig create() { + return new DiscordConfig("YOUR_DISCORD_TOKEN", -1, new long[0]); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/config/GeneralConfig.java b/src/main/java/net/forgecraft/services/ember/app/config/GeneralConfig.java new file mode 100644 index 0000000..6d98acc --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/config/GeneralConfig.java @@ -0,0 +1,13 @@ +package net.forgecraft.services.ember.app.config; + +import java.nio.file.Path; + +public record GeneralConfig( + Path storageDir, + Path databasePath +) { + + public static GeneralConfig create() { + return new GeneralConfig(Path.of("data/files"), Path.of("data/sqlite.db")); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/config/MinecraftServerConfig.java b/src/main/java/net/forgecraft/services/ember/app/config/MinecraftServerConfig.java new file mode 100644 index 0000000..bd640f9 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/config/MinecraftServerConfig.java @@ -0,0 +1,25 @@ +package net.forgecraft.services.ember.app.config; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Locale; + +public record MinecraftServerConfig( + String name, + Path path, + long uploadChannel +) { + + public static MinecraftServerConfig create() { + return new MinecraftServerConfig("Server 1", Path.of("server_1"), -1); + } + + public Path getNameAsPath(Path root) throws IOException { + // sadly cannot use Path#toRealPath because it doesn't work with non-existent paths + var normalized = root.resolve(name().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._]", "_")).normalize().toAbsolutePath(); + if (!normalized.startsWith(root.normalize().toAbsolutePath())) { + throw new IOException("Path " + normalized + " is outside of parent directory " + root); + } + return normalized; + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/config/ModrinthConfig.java b/src/main/java/net/forgecraft/services/ember/app/config/ModrinthConfig.java new file mode 100644 index 0000000..1ed89fe --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/config/ModrinthConfig.java @@ -0,0 +1,11 @@ +package net.forgecraft.services.ember.app.config; + +import java.util.Optional; + +public record ModrinthConfig( + Optional accessToken +) { + public static ModrinthConfig create() { + return new ModrinthConfig(Optional.empty()); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/CommonModInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/CommonModInfo.java deleted file mode 100644 index b0d7045..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/CommonModInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package net.forgecraft.services.ember.app.mods; - -public record CommonModInfo( - String id, - String name, - String version -) {} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/LazyTomlParser.java b/src/main/java/net/forgecraft/services/ember/app/mods/LazyTomlParser.java deleted file mode 100644 index 07310f1..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/LazyTomlParser.java +++ /dev/null @@ -1,68 +0,0 @@ -package net.forgecraft.services.ember.app.mods; - -/** - * I don't want to rely on a third-party library to parse TOML files, so I'm going to write something very lazy - * that will try it's best to get some of the information out of the file without properly parsing it. - */ -public class LazyTomlParser { - private final String toml; - private String cachedModsSection = null; - - public LazyTomlParser(String toml) { - this.toml = toml; - } - - public CommonModInfo parse() { - var id = extractValue("modId"); - var name = extractValue("displayName"); - var version = extractValue("version"); - - return new CommonModInfo(id, name, version); - } - - /** - * Extract a value from the TOML file - * This will always pull the first value it finds, so it's not very reliable... - * - * @param key The key to extract - * @return The value - */ - private String extractValue(String key) { - // Seek the mod specific area. This is denoted by the [[mods]] section and ends at the next [[ section ]] - if (cachedModsSection == null) { - var modsSectionStart = toml.indexOf("[[mods]]"); - if (modsSectionStart == -1) { - return ""; - } - - var modsSectionEnd = toml.indexOf("[[", modsSectionStart + 1); - if (modsSectionEnd == -1) { - return ""; - } - - cachedModsSection = toml.substring(modsSectionStart, modsSectionEnd); - } - - var keyIndex = cachedModsSection.indexOf(key); - if (keyIndex == -1) { - return ""; - } - - var valueIndex = cachedModsSection.indexOf("=", keyIndex); - if (valueIndex == -1) { - return ""; - } - - var startQuoteIndex = cachedModsSection.indexOf("\"", valueIndex); - if (startQuoteIndex == -1) { - return ""; - } - - var endQuoteIndex = cachedModsSection.indexOf("\"", startQuoteIndex + 1); - if (endQuoteIndex == -1) { - return ""; - } - - return cachedModsSection.substring(startQuoteIndex + 1, endQuoteIndex); - } -} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/ModFileManager.java b/src/main/java/net/forgecraft/services/ember/app/mods/ModFileManager.java new file mode 100644 index 0000000..7cd4527 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/ModFileManager.java @@ -0,0 +1,186 @@ +package net.forgecraft.services.ember.app.mods; + +import com.google.common.base.Preconditions; +import it.unimi.dsi.fastutil.Pair; +import net.forgecraft.services.ember.app.config.GeneralConfig; +import net.forgecraft.services.ember.app.config.MinecraftServerConfig; +import net.forgecraft.services.ember.app.mods.downloader.DownloadHelper; +import net.forgecraft.services.ember.app.mods.downloader.Hash; +import net.forgecraft.services.ember.app.mods.parser.CommonModInfo; +import net.forgecraft.services.ember.app.mods.parser.ModInfoParser; +import net.forgecraft.services.ember.db.schema.tables.records.ModFilesRecord; +import net.forgecraft.services.ember.util.Util; +import org.javacord.api.entity.user.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static net.forgecraft.services.ember.db.schema.Tables.*; + +public class ModFileManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(ModFileManager.class); + + public static CompletableFuture handleDownload(Hash sha512, Path filePath, User discordUser, long messageId) { + return recordDownload(sha512, filePath, discordUser).thenAccept(modInfo -> { + try (var db = Util.services().getDbConnection()) { + db.insertInto(APPROVAL_QUEUE, + APPROVAL_QUEUE.MESSAGE_ID, + APPROVAL_QUEUE.MOD_FILE_ID + ) + .values(messageId, modInfo.right()) + .execute(); + } + }); + } + + public static CompletableFuture> recordDownload(Hash sha512, Path filePath, User discordUser) { + Preconditions.checkArgument(sha512.type() == Hash.Type.SHA512, "Hash type must be SHA-512"); + return CompletableFuture.supplyAsync(() -> { + + var snowflake = discordUser.getId(); + var userDisplayName = discordUser.getName(); + + List modInfoList; + try { + modInfoList = ModInfoParser.parse(filePath); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read mod file", e); + } + + if (modInfoList.isEmpty()) { + throw new IllegalArgumentException("No mod info found"); + } + + String fileName = filePath.getFileName().toString(); + + try (var db = Util.services().getDbConnection()) { + Integer fileId = db.transactionResult(configuration -> { + // discord_users + configuration.dsl().insertInto(DISCORD_USERS, + DISCORD_USERS.SNOWFLAKE, + DISCORD_USERS.DISPLAY_NAME + ) + .values(snowflake, userDisplayName) + .onDuplicateKeyUpdate() + .set(DISCORD_USERS.DISPLAY_NAME, userDisplayName).execute(); + + + for (CommonModInfo modInfo : modInfoList) { + + // mods + configuration.dsl().insertInto(MODS, + MODS.ID, + MODS.PROJECT_URL, + MODS.ISSUES_URL + ) + .values(modInfo.id(), modInfo.projectUrl().orElse(null), modInfo.issuesUrl().orElse(null)) + .onConflictDoNothing().execute(); + + // mod_owners + var owner = configuration.dsl().fetchAny(MOD_OWNERS, MOD_OWNERS.MOD_ID.eq(modInfo.id())); + + // only create owner entry on first upload + if (owner == null) { + configuration.dsl().insertInto(MOD_OWNERS, + MOD_OWNERS.MOD_ID, + MOD_OWNERS.USER_ID + ) + .values(modInfo.id(), discordUser.getId()).execute(); + } + } + + var rootMod = modInfoList.getFirst(); + + // mod_files + var modFilesRecord = configuration.dsl().insertInto(MOD_FILES, + MOD_FILES.MOD_ID, + MOD_FILES.UPLOADER_ID, + MOD_FILES.MOD_VERSION, + MOD_FILES.FILE_NAME, + MOD_FILES.SHA_512 + ) + .values(rootMod.id(), snowflake, rootMod.version(), fileName, sha512.byteValue()) + .onConflictDoNothing().returning(MOD_FILES.ID).fetchOne(); + Preconditions.checkNotNull(modFilesRecord, "Unable to create record for: " + filePath); + + //TODO audit log + + return modFilesRecord.getId(); + }); + return Pair.of(modInfoList.getFirst(), fileId); + } + }, Util.BACKGROUND_EXECUTOR); + } + + public static CompletableFuture handleApproval(long userId, long messageId, GeneralConfig cfg, MinecraftServerConfig serverCfg) { + return CompletableFuture.runAsync(() -> { + + var serverPath = serverCfg.path(); + + try (var db = Util.services().getDbConnection()) { + db.transaction(configuration -> { + var pendingRecord = configuration.dsl().deleteFrom(APPROVAL_QUEUE) + .where(APPROVAL_QUEUE.MESSAGE_ID.eq(messageId)) + .returning(APPROVAL_QUEUE.MOD_FILE_ID) + .fetchOne(); + + if (pendingRecord == null) { + // does not exist, do nothing + return; + } + + var modFileRecord = configuration.dsl().select(MOD_FILES.MOD_ID) + .from(MOD_FILES) + .where(MOD_FILES.ID.eq(pendingRecord.getModFileId())) + .fetchOne(); + + Preconditions.checkNotNull(modFileRecord, "No database record found for mod file " + pendingRecord.getModFileId()); + + //delete old file(s) if exists and mark inactive + var activeFiles = configuration.dsl().update(MOD_FILES) + .set(MOD_FILES.ACTIVE, false) + .where(MOD_FILES.MOD_ID.eq(modFileRecord.value1()).and(MOD_FILES.ACTIVE.eq(true))) + .returning(MOD_FILES.FILE_NAME) + .fetch(); + + for (ModFilesRecord file : activeFiles) { + var filePath = serverPath.resolve(file.getFileName()).normalize(); + if (!filePath.startsWith(serverPath.normalize())) { + throw new IllegalArgumentException("Invalid file path " + filePath + " for server path " + serverPath); + } + Files.deleteIfExists(filePath); + } + + // create new file in target location + var newFileInfo = configuration.dsl().update(MOD_FILES) + .set(MOD_FILES.ACTIVE, true) + .where(MOD_FILES.ID.eq(pendingRecord.getModFileId())) + .returning(MOD_FILES.FILE_NAME, MOD_FILES.SHA_512) + .fetchOne(); + Preconditions.checkNotNull(newFileInfo, "No database record found for mod file " + pendingRecord.getModFileId()); + + var hash = Hash.fromBytes(Hash.Type.SHA512, newFileInfo.getSha_512()); + var cacheFileName = DownloadHelper.getCacheFileName(cfg, serverCfg, hash, newFileInfo.getFileName()); + Preconditions.checkState(Files.exists(cacheFileName), "File not found in cache " + cacheFileName); + + var targetPath = serverPath.resolve(newFileInfo.getFileName()).normalize(); + if (!targetPath.startsWith(serverPath.normalize())) { + throw new IllegalArgumentException("Invalid file path " + targetPath + " for server path " + serverPath); + } + Files.createDirectories(targetPath.getParent()); + Files.copy(cacheFileName, targetPath, StandardCopyOption.REPLACE_EXISTING); + + //TODO audit log + }); + } + }, Util.BACKGROUND_EXECUTOR); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/ModInfoParser.java b/src/main/java/net/forgecraft/services/ember/app/mods/ModInfoParser.java deleted file mode 100644 index 27cf85b..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/ModInfoParser.java +++ /dev/null @@ -1,97 +0,0 @@ -package net.forgecraft.services.ember.app.mods; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; - -/** - * Takes in a mod of any form and attempts to parse it's metadata by either reading it's mods.toml file or from it's - * fabric.mod.json file. - * - * // TODO: Maybe have special support for server plugins? - */ -public class ModInfoParser { - private static final Logger LOGGER = LoggerFactory.getLogger(ModInfoParser.class); - - private final Path modPath; - - public ModInfoParser(Path modPath) { - this.modPath = modPath; - } - - /** - * Attempt to parse the mod's metadata - * - * @return The mod's metadata - */ - public CommonModInfo parse() throws IOException, RuntimeException { - var seekInfoFile = this.seekInfoFile(); - if (seekInfoFile == null) { - throw new RuntimeException("The mod does not contain a mods.toml or fabric.mod.json file"); - } - - if (seekInfoFile[0].equals("mods.toml")) { - return this.parseModsToml(seekInfoFile[1]); - } else if (seekInfoFile[0].equals("fabric.mod.json")) { - return this.parseFabricModJson(seekInfoFile[1]); - } - - throw new RuntimeException("Unknown mod metadata file: " + seekInfoFile[0]); - } - - private CommonModInfo parseModsToml(String fileContents) throws RuntimeException { - var parser = new LazyTomlParser(fileContents); - - CommonModInfo result = parser.parse(); - // Test for the required fields - if (result.id().isEmpty() || result.name().isEmpty() || result.version().isEmpty()) { - throw new RuntimeException("The mods.toml file is missing required fields"); - } - - return result; - } - - private CommonModInfo parseFabricModJson(String fileContents) throws RuntimeException, IOException { - var mapper = new ObjectMapper(); - var data = mapper.readValue(fileContents, Map.class); - - if (!data.containsKey("id") || !data.containsKey("name") || !data.containsKey("version")) { - throw new RuntimeException("The fabric.mod.json file is missing required fields"); - } - - // The unsafe nature here isn't great, but it's the best we can do without a proper schema, and I don't want to - // make one myself. - var modId = (String) data.get("id"); - var modName = (String) data.get("name"); - var modVersion = (String) data.get("version"); - - return new CommonModInfo(modId, modName, modVersion); - } - - @Nullable - private String[] seekInfoFile() throws IOException { - // Create a file system for the jar - try (FileSystem system = FileSystems.newFileSystem(modPath)) { - // Seek a mods.toml file or a fabric.mod.json file - var modsToml = system.getPath("META-INF/mods.toml"); - if (Files.exists(modsToml)) { - return new String[]{"mods.toml", Files.readString(modsToml)}; - } - - var fabricModJson = system.getPath("fabric.mod.json"); - if (Files.exists(fabricModJson)) { - return new String[]{"fabric.mod.json", Files.readString(fabricModJson)}; - } - } - - return null; - } -} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/ModVersionComparator.java b/src/main/java/net/forgecraft/services/ember/app/mods/ModVersionComparator.java deleted file mode 100644 index af63697..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/ModVersionComparator.java +++ /dev/null @@ -1,4 +0,0 @@ -package net.forgecraft.services.ember.app.mods; - -public class ModVersionComparator { -} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/CurseForgeDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/CurseForgeDownloader.java deleted file mode 100644 index 8675f91..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/CurseForgeDownloader.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.forgecraft.services.ember.app.mods.downloader; - -import org.jetbrains.annotations.Nullable; - -import java.nio.file.Path; - -/** - * A downloader for CurseForge - *

- * Expected inputs: - * - https://www.curseforge.com/minecraft/mc-mods/simply-graves/files/5126737 // We need to look this up with the api - * - https://mediafilez.forgecdn.net/files/5126/737/simplygraves-1.19.2-1.1.0-build.19.jar // We can use this directly - * - curseforge:simply-graves:1.19.2 // Maybe? We need to look this up with the api - */ -public class CurseForgeDownloader implements Downloader { - @Override - public boolean isAcceptable(String inputData) { - return inputData.startsWith("https://curseforge.com") || inputData.startsWith("https://www.curseforge.com") || inputData.startsWith("curseforge:"); - } - - @Override - public @Nullable Path download(String inputData) { - System.out.println("CurseForgeDownloader: " + inputData); - return null; - } - - /** - * Attempts to parse out the correct download link from a given curseforge link - * - * @param inputData The input data - * @return The download link - */ - @Nullable - private String lookup(String inputData) { - return null; - } -} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloadHelper.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloadHelper.java new file mode 100644 index 0000000..4263631 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloadHelper.java @@ -0,0 +1,31 @@ +package net.forgecraft.services.ember.app.mods.downloader; + +import com.google.common.base.Preconditions; +import net.forgecraft.services.ember.app.config.GeneralConfig; +import net.forgecraft.services.ember.app.config.MinecraftServerConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +public class DownloadHelper { + + private static final Logger LOGGER = LoggerFactory.getLogger(DownloadHelper.class); + + public static void saveTo(Path path, byte[] data) throws IOException { + Files.createDirectories(path.getParent()); + + // throw if already exists + Files.write(path, data, StandardOpenOption.CREATE_NEW); + + LOGGER.debug("Successfully saved file to {}", path); + } + + public static Path getCacheFileName(GeneralConfig cfg, MinecraftServerConfig serverCfg, Hash hash, String fileName) throws IOException { + Preconditions.checkArgument(hash.type() == Hash.Type.SHA512, "Hash type must be SHA-512"); + return serverCfg.getNameAsPath(cfg.storageDir()).resolve(hash.stringValue()).resolve(fileName); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloadInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloadInfo.java new file mode 100644 index 0000000..4fba287 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloadInfo.java @@ -0,0 +1,28 @@ +package net.forgecraft.services.ember.app.mods.downloader; + +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +public interface DownloadInfo { + + URI getUrl(); + + /** + * Get the SHA-512 hash of the downloaded file.
+ * If this method is called before {@link DownloadInfo#isComplete()} returns true, and the hash is not known beforehand, it will return {@code null}. + */ + @Nullable + Hash getSha512(); + + boolean isComplete(); + + String getFileName(); + + CompletableFuture getFileContents(); + + void start(); + + void cancel(); +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/Downloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/Downloader.java index 8dcc9a7..41215be 100644 --- a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/Downloader.java +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/Downloader.java @@ -2,8 +2,6 @@ import org.jetbrains.annotations.Nullable; -import java.nio.file.Path; - public interface Downloader { /** * Check if the input data is acceptable for this downloader to handle @@ -17,8 +15,6 @@ public interface Downloader { * Attempt to download from the given input data * * @param inputData The input data - * @return The path to the downloaded file */ - @Nullable - Path download(String inputData); + @Nullable DownloadInfo createDownloadInstance(String inputData); } diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloaderFactory.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloaderFactory.java index 93f05fd..406dafc 100644 --- a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloaderFactory.java +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/DownloaderFactory.java @@ -1,27 +1,37 @@ package net.forgecraft.services.ember.app.mods.downloader; +import com.google.common.base.Suppliers; +import net.forgecraft.services.ember.app.mods.downloader.curseforge.CurseForgeDownloader; +import net.forgecraft.services.ember.app.mods.downloader.maven.MavenDownloader; +import net.forgecraft.services.ember.app.mods.downloader.modrinth.ModrinthDownloader; +import net.forgecraft.services.ember.app.mods.downloader.plain.PlainUrlDownloader; +import net.forgecraft.services.ember.util.Util; import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.List; +import java.util.function.Supplier; public enum DownloaderFactory { INSTANCE; + private static final Logger LOGGER = LoggerFactory.getLogger(DownloaderFactory.class); + /** * The order of the downloaders is important as it will be used to determine which downloader to use first. * If there is no special matches, we'll fall back to the url downloader. - */ - private final List downloaders = List.of( - new ModrinthDownloader(), - new CurseForgeDownloader(), - new MavenDownloader(), - new UrlDownloader() - ); + private final Supplier> downloaders = Suppliers.memoize(() -> List.of( + new ModrinthDownloader(Util.services().getConfig().getModrinth(), Util::newHttpClient), + new CurseForgeDownloader(Util.services().getConfig().getCurseforge(), Util::newHttpClient), + new MavenDownloader(Util::newHttpClient, Util.KNOWN_MAVENS), + new PlainUrlDownloader(Util::newHttpClient, Util.OPT_ALLOW_INSECURE_DOWNLOADS) + )); @Nullable public Downloader factory(String inputData) { - for (var downloader : downloaders) { + for (var downloader : downloaders.get()) { if (downloader.isAcceptable(inputData)) { return downloader; } @@ -29,4 +39,20 @@ public Downloader factory(String inputData) { return null; } + + @Nullable + public DownloadInfo tryDownload(String inputData) { + var downloader = factory(inputData); + + if (downloader != null) { + LOGGER.debug("found valid download: {}", inputData); + var dl = downloader.createDownloadInstance(inputData); + if(dl != null) { + dl.start(); + } + return dl; + } + + return null; + } } diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/Hash.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/Hash.java new file mode 100644 index 0000000..90bb95e --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/Hash.java @@ -0,0 +1,59 @@ +package net.forgecraft.services.ember.app.mods.downloader; + +import com.google.common.hash.HashCode; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; + +public record Hash(Type type, HashCode value) { + + public enum Type { + SHA512("sha512"), + SHA256("sha256"), + @Deprecated + SHA1("sha1"), + @Deprecated + MD5("md5"); + + private final String name; + + Type(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + public static Hash fromString(Type type, String hash) { + return new Hash(type, HashCode.fromString(hash)); + } + + public static Hash fromBytes(Type type, byte[] value) { + return new Hash(type, HashCode.fromBytes(value)); + } + + @SuppressWarnings("deprecation") + public static Hash of(Type type, byte[] data) { + HashFunction function = switch (type) { + case SHA512 -> Hashing.sha512(); + case SHA256 -> Hashing.sha256(); + case SHA1 -> Hashing.sha1(); + case MD5 -> Hashing.md5(); + }; + return new Hash(type, function.hashBytes(data)); + } + + public String toString() { + return type() + ":" + value().toString(); + } + + public String stringValue() { + return value().toString(); + } + + public byte[] byteValue() { + return value().asBytes(); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/MavenDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/MavenDownloader.java deleted file mode 100644 index 1b29a95..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/MavenDownloader.java +++ /dev/null @@ -1,123 +0,0 @@ -package net.forgecraft.services.ember.app.mods.downloader; - -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Path; -import java.util.List; - -/** - * Resolves a maven artifact classifier (com.example:artifact:version:classifier) to a path, then queries each - * trusted maven repository for the artifact, downloading it if found. - */ -public class MavenDownloader implements Downloader { - private static final Logger LOGGER = LoggerFactory.getLogger(MavenDownloader.class); - - private final HttpClient client = HttpClient.newHttpClient(); - - // TODO: Add more trusted maven repositories - /** - * We're basically just acting as a proxy for these mavens so we can download the artifacts - */ - private static final List TRUSTED_MAVENS = List.of( - "https://maven.blamejared.com", - "https://modmaven.k-4u.nl", - "https://maven.saps.dev/releases", - "https://maven.saps.dev/snapshots", - "https://maven.nanite.dev/releases", - "https://maven.nanite.dev/snapshots", - "https://maven.fabricmc.net", - "https://maven.creeperhost.net", - "https://maven.minecraftforge.net", - "https://maven.neoforged.net/releases", - "https://repo.spongepowered.org/maven" - ); - - @Override - public @Nullable Path download(String inputData) { - var resolvedPath = resolvePath(inputData); - if (resolvedPath == null) { - return null; - } - - var artifactPath = lookupArtifact(resolvedPath); - if (artifactPath == null) { - return null; - } - - // It exists! Download it - var request = HttpRequest.newBuilder() - .uri(URI.create(artifactPath)) - .GET() - .build(); - - try { - String fileName = artifactPath.substring(artifactPath.lastIndexOf('/') + 1); - Path file = Path.of("downloads", fileName); - LOGGER.debug("Downloading " + artifactPath + " to " + file); - var response = client.send(request, HttpResponse.BodyHandlers.ofFile(file)); - if (response.statusCode() == 200) { - return response.body(); - } - } catch (Exception e) { - LOGGER.error("Failed to download artifact", e); - } - - return null; - } - - // TODO: Tests! - @Nullable - private String resolvePath(String classifier) { - var parts = classifier.replace("maven:", "").split(":"); - var domain = parts[0].replace(".", "/"); - - if (parts.length == 3) { - return domain + "/" + parts[1] + "/" + parts[2] + "/" + parts[1] + "-" + parts[2] + ".jar"; - } - - if (parts.length == 4) { - return domain + "/" + parts[1] + "/" + parts[2] + "/" + parts[1] + "-" + parts[2] + "-" + parts[3] + ".jar"; - } - - return null; - } - - /** - * Iterates through the trusted maven repositories and creates a head request to check if the artifact exists. - * @param path the resolved path to the artifact - * @return the path to the artifact if found, null otherwise - */ - @Nullable - private String lookupArtifact(String path) { - for (String maven : TRUSTED_MAVENS) { - var request = HttpRequest.newBuilder() - .uri(URI.create(maven + "/" + path)) - .method("HEAD", HttpRequest.BodyPublishers.noBody()) - .build(); - - LOGGER.debug("Looking up " + request.uri()); - - try { - var response = client.send(request, HttpResponse.BodyHandlers.discarding()); - if (response.statusCode() == 200) { - return maven + "/" + path; - } - } catch (Exception e) { - LOGGER.error("Failed to lookup artifact", e); - } - } - - return null; - } - - @Override - public boolean isAcceptable(String inputData) { - return inputData.startsWith("maven:"); - } -} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/ModrinthDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/ModrinthDownloader.java deleted file mode 100644 index 70f2bcb..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/ModrinthDownloader.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.forgecraft.services.ember.app.mods.downloader; - -import org.jetbrains.annotations.Nullable; - -import java.nio.file.Path; - -/** - * A downloader for Modrinth - *

- * Expected inputs: - * - https://modrinth.com/mod/emi/version/1.1.2+1.20.4+neoforge // We need to look this up with the api - * - 8qHA9xh2 // This is truly unique and we can use the api to look it up - * - https://cdn.modrinth.com/data/fRiHVvU7/versions/8qHA9xh2/emi-1.1.2%2B1.20.4%2Bneoforge.jar // We can use this directly. This should just fallback to the url downloader. There is no need to have a separate downloader for this. - */ -public class ModrinthDownloader implements Downloader { - @Override - public boolean isAcceptable(String inputData) { - return inputData.startsWith("https://modrinth.com") || inputData.startsWith("https://www.modrinth.com") || inputData.startsWith("modrinth:"); - } - - @Override - public @Nullable Path download(String inputData) { - System.out.println("ModrinthDownloader: " + inputData); - return null; - } -} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/UrlDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/UrlDownloader.java deleted file mode 100644 index dcd4d4e..0000000 --- a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/UrlDownloader.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.forgecraft.services.ember.app.mods.downloader; - -import org.jetbrains.annotations.Nullable; - -import java.nio.file.Path; - -public class UrlDownloader implements Downloader { - @Override - public boolean isAcceptable(String inputData) { - return inputData.startsWith("https://"); - } - - @Override - public @Nullable Path download(String inputData) { - System.out.println("UrlDownloader: " + inputData); - return null; - } -} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CfWidgetApiResponse.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CfWidgetApiResponse.java new file mode 100644 index 0000000..94f02c0 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CfWidgetApiResponse.java @@ -0,0 +1,7 @@ +package net.forgecraft.services.ember.app.mods.downloader.curseforge; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record CfWidgetApiResponse(long id, String title) { +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CurseForgeDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CurseForgeDownloader.java new file mode 100644 index 0000000..20a58aa --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CurseForgeDownloader.java @@ -0,0 +1,136 @@ +package net.forgecraft.services.ember.app.mods.downloader.curseforge; + +import io.github.matyrobbrt.curseforgeapi.CurseForgeAPI; +import io.github.matyrobbrt.curseforgeapi.request.Requests; +import io.github.matyrobbrt.curseforgeapi.util.CurseForgeException; +import net.forgecraft.services.ember.app.config.CurseforgeConfig; +import net.forgecraft.services.ember.app.mods.downloader.DownloadInfo; +import net.forgecraft.services.ember.app.mods.downloader.Downloader; +import net.forgecraft.services.ember.util.serialization.JsonResponseHandler; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.net.URI; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** + * A downloader for CurseForge + *

+ * Expected inputs: + * - curseforge:simplygraves:5126737 + * - curseforge:620112:5126737 + * - https://www.curseforge.com/minecraft/mc-mods/simply-graves/files/5126737 + * - https://www.curseforge.com/minecraft/mc-mods/simply-graves/download/5126737 + * - https://legacy.curseforge.com/minecraft/mc-mods/simply-graves/download/5126737 + * - https://legacy.curseforge.com/minecraft/mc-mods/simply-graves/download/5126737 + * Inputs we DONT handle: + * - https://mediafilez.forgecdn.net/files/5126/737/simplygraves-1.19.2-1.1.0-build.19.jar // already handled by the plain URL downloader + * - https://www.curseforge.com/projects/620112 // project page, not a file; adding a file to that URL makes it invalid + */ +public class CurseForgeDownloader implements Downloader { + + public static final String CURSEFORGE_API_KEY_HEADER = "X-Api-Key"; + + private static final Pattern CURSEFORGE_ID_PATTERN = Pattern.compile("(?:curseforge|cf):(?:(?\\w+):)?(?\\d+)"); + private static final Pattern CURSEFORGE_URL_PATTERN = Pattern.compile("https://(?:legacy|www)\\.curseforge\\.com/minecraft/mc-mods/(?.+)/(?:files|download)/(?\\d+)"); + private static final Logger LOGGER = LoggerFactory.getLogger(CurseForgeDownloader.class); + + private final CurseforgeConfig cfg; + private final Supplier clientFactory; + private final CurseForgeAPI apiClient; + + public CurseForgeDownloader(CurseforgeConfig cfg, Supplier clientFactory) { + this.cfg = cfg; + this.clientFactory = clientFactory; + CurseForgeAPI apiClient; + try { + apiClient = CurseForgeAPI.builder().apiKey(cfg.accessToken().orElseThrow(() -> new LoginException("No access token provided in config"))).build(); + } catch (LoginException e) { + apiClient = null; + LOGGER.warn("Failed to login to CurseForge API: {}", e.getMessage()); + } + this.apiClient = apiClient; + } + + @Override + public boolean isAcceptable(String inputData) { + return CURSEFORGE_ID_PATTERN.asMatchPredicate() + .or(CURSEFORGE_URL_PATTERN.asMatchPredicate()) + .test(inputData); + } + + @Override + public @Nullable DownloadInfo createDownloadInstance(String inputData) { + if (this.apiClient == null) { + LOGGER.error("Unable to download {}: Curseforge API support is not available, check startup log for details!", inputData); + return null; + } + + // parse input + String slug = null; + long fileID = -1; + try { + var matcher = CURSEFORGE_ID_PATTERN.matcher(inputData); + if (matcher.matches()) { + slug = matcher.group("slug"); + fileID = Long.parseUnsignedLong(matcher.group("fileID")); + } else { + matcher = CURSEFORGE_URL_PATTERN.matcher(inputData); + if (matcher.matches()) { + slug = matcher.group("slug"); + fileID = Long.parseUnsignedLong(matcher.group("fileID")); + } + } + } catch (NumberFormatException ignore) { + } + if (slug == null || fileID < 0) { + LOGGER.error("Failed to parse Curseforge version from {}", inputData); + return null; + } + + // look up project ID since we only have the slug and CF's own API does not provide a mapping from slug to project ID + long projectID; + try { + var client = clientFactory.get(); + var cfwidgetResponse = client.execute(new HttpGet("https://api.cfwidget.com/minecraft/mc-mods/%s".formatted(slug)), JsonResponseHandler.of(CfWidgetApiResponse.class)); + LOGGER.debug("Found project {}: {}", cfwidgetResponse.id(), cfwidgetResponse.title()); + projectID = cfwidgetResponse.id(); + } catch (IOException e) { + LOGGER.error("Failed to look up project ID from CfWdiget for input {}", inputData, e); + return null; + } + + // look up project properties via curseforge's API + try { + var response = this.apiClient.makeRequest(Requests.getMod((int) projectID)); + if (response.isEmpty()) { + LOGGER.error("Unable to look up project ID for {}", projectID); + return null; + } + var mod = response.get(); + if (!mod.allowModDistribution()) { + LOGGER.error("Mod {} does not allow third party distribution", mod.name()); + //TODO call back to uploader to react with a lock symbol + return null; + } + + var downloadUrlResponse = this.apiClient.makeRequest(Requests.getModFileDownloadURL((int) projectID, (int) fileID)); + if (downloadUrlResponse.isEmpty()) { + LOGGER.error("Unable to look up download URL for file {}", fileID); + return null; + } + var downloadUrl = URI.create(downloadUrlResponse.get()); + + return new CurseforgeDownloadInfo(downloadUrl, clientFactory.get(), cfg, mod, fileID); + } catch (CurseForgeException e) { + LOGGER.error("Failed to look up mod info for {}", inputData, e); + return null; + } + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CurseforgeDownloadInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CurseforgeDownloadInfo.java new file mode 100644 index 0000000..fc93cb8 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/curseforge/CurseforgeDownloadInfo.java @@ -0,0 +1,38 @@ +package net.forgecraft.services.ember.app.mods.downloader.curseforge; + +import io.github.matyrobbrt.curseforgeapi.schemas.mod.Mod; +import net.forgecraft.services.ember.app.config.CurseforgeConfig; +import net.forgecraft.services.ember.app.mods.downloader.plain.SimpleDownloadInfo; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; + +import java.net.URI; + +public class CurseforgeDownloadInfo extends SimpleDownloadInfo { + + private final CurseforgeConfig cfg; + private final Mod project; + private final long fileId; + + public CurseforgeDownloadInfo(URI url, HttpClient client, CurseforgeConfig cfg, Mod project, long fileId) { + super(url, client); + this.cfg = cfg; + this.project = project; + this.fileId = fileId; + } + + @Override + protected HttpUriRequestBase createRequest() { + var request = super.createRequest(); + cfg.accessToken().ifPresent(token -> request.addHeader(CurseForgeDownloader.CURSEFORGE_API_KEY_HEADER, token)); + return request; + } + + public Mod getProject() { + return project; + } + + public long getFileId() { + return fileId; + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/ArtifactInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/ArtifactInfo.java new file mode 100644 index 0000000..579bf1a --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/ArtifactInfo.java @@ -0,0 +1,56 @@ +package net.forgecraft.services.ember.app.mods.downloader.maven; + +import net.forgecraft.services.ember.app.mods.downloader.Hash; +import org.jetbrains.annotations.Nullable; + +public record ArtifactInfo(String group, String artifact, String version, @Nullable String classifier, + String extension) { + + public static ArtifactInfo fromString(String input) { + var matcher = MavenDownloader.DEPENDENCY_NOTATION_PATTERN.matcher(input); + if (!matcher.matches()) { + throw new IllegalArgumentException("Invalid dependency notation: " + input); + } + + // guaranteed to exist + var group = matcher.group("group"); + var artifact = matcher.group("artifact"); + var version = matcher.group("version"); + + // optional + var classifier = matcher.group("classifier"); + if (classifier != null && classifier.isBlank()) { + classifier = null; + } + var extension = matcher.group("extension"); + if (extension == null) { + extension = "jar"; + } + + return new ArtifactInfo(group, artifact, version, classifier, extension); + } + + public String toUrlPath() { + // group/artifact/version/artifact-version-classifier.extension + return '/' + group().replace('.', '/') + '/' + artifact() + '/' + version() + '/' + getFileName(); + } + + @SuppressWarnings("deprecation") + public String toHashFilePath(Hash.Type type) { + return toUrlPath() + "." + switch (type) { + case SHA512 -> "sha512"; + case SHA256 -> "sha256"; + case SHA1 -> "sha1"; + case MD5 -> "md5"; + }; + } + + public String getFileName() { + return artifact() + "-" + version() + (classifier() != null ? "-" + classifier() : "") + "." + extension(); + } + + @Override + public String toString() { + return "maven:" + group() + ":" + artifact() + ":" + version() + (classifier() != null ? ":" + classifier() : "") + "@" + extension(); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/MavenDownloadInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/MavenDownloadInfo.java new file mode 100644 index 0000000..39f061d --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/MavenDownloadInfo.java @@ -0,0 +1,30 @@ +package net.forgecraft.services.ember.app.mods.downloader.maven; + +import net.forgecraft.services.ember.app.mods.downloader.Hash; +import net.forgecraft.services.ember.app.mods.downloader.plain.SimpleDownloadInfo; +import org.apache.hc.client5.http.classic.HttpClient; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; + +class MavenDownloadInfo extends SimpleDownloadInfo { + + private static final Logger LOGGER = LoggerFactory.getLogger(MavenDownloadInfo.class); + private final ArtifactInfo artifact; + + public MavenDownloadInfo(String mavenURL, ArtifactInfo artifact, @Nullable Hash sha512, HttpClient client) { + super(URI.create(mavenURL + "/" + artifact.toUrlPath()), artifact.getFileName(), sha512, client); + this.artifact = artifact; + } + + @Override + protected void printStartMessage() { + LOGGER.debug("Downloading {} from {}", getArtifact().getFileName(), getUrl()); + } + + public ArtifactInfo getArtifact() { + return artifact; + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/MavenDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/MavenDownloader.java new file mode 100644 index 0000000..adce20e --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/maven/MavenDownloader.java @@ -0,0 +1,134 @@ +package net.forgecraft.services.ember.app.mods.downloader.maven; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.forgecraft.services.ember.app.mods.downloader.DownloadInfo; +import net.forgecraft.services.ember.app.mods.downloader.Downloader; +import net.forgecraft.services.ember.app.mods.downloader.Hash; +import net.forgecraft.services.ember.util.Util; +import net.forgecraft.services.ember.util.serialization.StatusCodeOnlyResponseHandler; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpHead; +import org.apache.hc.client5.http.impl.classic.BasicHttpClientResponseHandler; +import org.apache.hc.core5.http.HttpStatus; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** + * Resolves a maven artifact classifier (maven:group:artifact:version:classifier@extension) to a path, then queries each + * known maven repository for the artifact, downloading it if found. + */ +public class MavenDownloader implements Downloader { + + private final Map artifactLocatorCache = new Object2ObjectOpenHashMap<>(); + + private static final Logger LOGGER = LoggerFactory.getLogger(MavenDownloader.class); + static final Pattern DEPENDENCY_NOTATION_PATTERN = Pattern.compile("maven:(?[\\w.-]+):(?[\\w.-]+):(?[\\w.+-]+)(:(?[\\w-]+))?(@(?[\\w-]+))?"); + + private final Supplier clientFactory; + private final List knownMavenUrls; + + public MavenDownloader(Supplier clientFactory, List knownMavenUrls) { + this.clientFactory = clientFactory; + this.knownMavenUrls = knownMavenUrls.stream().map(Util::stripTrailingSlash).toList(); + } + + //FIXME sending a HEAD request first is pointless, just try downloading immediately + + /** + * Iterates through the known maven repositories and creates a head request to check if the artifact exists. + * + * @return the URL of the first maven that contains the artifact, or {@code null} if not found + */ + @Nullable + private String lookupArtifact(ArtifactInfo info, HttpClient client) { + var cacheKey = info.group() + ":" + info.artifact(); + + // check cache first to not need to query all mavens all the time + String cachedMaven; + synchronized (artifactLocatorCache) { + cachedMaven = artifactLocatorCache.get(cacheKey); + } + + if (cachedMaven != null && checkArtifactExists(cachedMaven, info, client)) { + return cachedMaven; + } + + for (String maven : knownMavenUrls) { + if (checkArtifactExists(maven, info, client)) { + + // update cache + synchronized (artifactLocatorCache) { + artifactLocatorCache.put(cacheKey, maven); + } + + return maven; + } + } + + return null; + } + + private static boolean checkArtifactExists(String maven, ArtifactInfo artifact, HttpClient client) { + + var request = new HttpHead(URI.create(maven + "/" + artifact.toUrlPath())); + + LOGGER.trace("Checking {}", request.getRequestUri()); + + try { + var responseStatus = client.execute(request, StatusCodeOnlyResponseHandler.INSTANCE); + if (responseStatus == HttpStatus.SC_OK) { + LOGGER.debug("Found artifact {} at {}", artifact, maven); + return true; + } + } catch (IOException e) { + LOGGER.error("Failed to look up artifact {} in maven {}", artifact, maven, e); + } + + return false; + } + + @Override + public boolean isAcceptable(String inputData) { + return DEPENDENCY_NOTATION_PATTERN.asMatchPredicate().test(inputData); + } + + @Override + public @Nullable DownloadInfo createDownloadInstance(String inputData) { + var client = clientFactory.get(); + var artifact = ArtifactInfo.fromString(inputData); + var mavenUrl = lookupArtifact(artifact, client); + if (mavenUrl == null) { + LOGGER.error("Unable to locate artifact: {}", inputData); + return null; + } + + // It exists! try to download the hash first + @Nullable Hash hash = downloadHashFromUrl(mavenUrl, artifact, Hash.Type.SHA512, client); + return new MavenDownloadInfo(mavenUrl, artifact, hash, client); + } + + @Nullable + private static Hash downloadHashFromUrl(String mavenUrl, ArtifactInfo artifact, Hash.Type hashType, HttpClient client) { + try { + var url = URI.create(mavenUrl + "/" + artifact.toHashFilePath(hashType)); + LOGGER.trace("Trying to download hash file {}", url); + var request = new HttpGet(url); + var rawHash = client.execute(request, new BasicHttpClientResponseHandler()).strip(); + LOGGER.trace("Hash for {} is {}", artifact, rawHash); + return Hash.fromString(hashType, rawHash); + } catch (IOException e) { + // ignore exception, we'll just download the file without the hash + LOGGER.debug("Failed to download {} hash for {}", hashType, artifact, e); + } + return null; + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/ModrinthDownloadInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/ModrinthDownloadInfo.java new file mode 100644 index 0000000..40b3b68 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/ModrinthDownloadInfo.java @@ -0,0 +1,25 @@ +package net.forgecraft.services.ember.app.mods.downloader.modrinth; + +import net.forgecraft.services.ember.app.config.ModrinthConfig; +import net.forgecraft.services.ember.app.mods.downloader.Hash; +import net.forgecraft.services.ember.app.mods.downloader.plain.SimpleDownloadInfo; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; + +public class ModrinthDownloadInfo extends SimpleDownloadInfo { + + private final ModrinthConfig cfg; + + public ModrinthDownloadInfo(URI url, String fileName, @Nullable Hash expectedHash, HttpClient client, ModrinthConfig cfg) { + super(url, fileName, expectedHash, client); + this.cfg = cfg; + } + + @Override + protected HttpUriRequestBase createRequest() { + return ModrinthDownloader.addAuthHeader(super.createRequest(), cfg); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/ModrinthDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/ModrinthDownloader.java new file mode 100644 index 0000000..caa689e --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/ModrinthDownloader.java @@ -0,0 +1,121 @@ +package net.forgecraft.services.ember.app.mods.downloader.modrinth; + +import net.forgecraft.services.ember.app.config.ModrinthConfig; +import net.forgecraft.services.ember.app.mods.downloader.DownloadInfo; +import net.forgecraft.services.ember.app.mods.downloader.Downloader; +import net.forgecraft.services.ember.app.mods.downloader.Hash; +import net.forgecraft.services.ember.app.mods.downloader.modrinth.api.ModrinthVersionResponse; +import net.forgecraft.services.ember.util.serialization.JsonResponseHandler; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** + * A downloader for Modrinth + *

+ * Expected inputs: + * - https://(www.)modrinth.com/mod/emi/version/1.1.2+1.20.4+neoforge // We need to look this up with the api + * - 8qHA9xh2 // This is truly unique and we can use the api to look it up + * - https://cdn.modrinth.com/data/fRiHVvU7/versions/8qHA9xh2/emi-1.1.2%2B1.20.4%2Bneoforge.jar // We can use this directly. This should just fallback to the url downloader. There is no need to have a separate downloader for this. + */ +public class ModrinthDownloader implements Downloader { + + private static final String API_URL = "https://api.modrinth.com/v2"; + private static final Pattern MODRINTH_ID_PATTERN = Pattern.compile("(?:modrinth|mr):(?:(?\\w+):)?(?\\w+)"); + private static final Pattern MODRINTH_URL_PATTERN = Pattern.compile("https://(www\\.)?modrinth\\.com/mod/(?.+)/version/(?.+)"); + private static final Logger LOGGER = LoggerFactory.getLogger(ModrinthDownloader.class); + + private final ModrinthConfig cfg; + private final Supplier clientFactory; + + public ModrinthDownloader(ModrinthConfig cfg, Supplier clientFactory) { + this.cfg = cfg; + this.clientFactory = clientFactory; + } + + @Override + public boolean isAcceptable(String inputData) { + return MODRINTH_ID_PATTERN.asMatchPredicate() + .or(MODRINTH_URL_PATTERN.asMatchPredicate()) + .test(inputData); + } + + @Override + public @Nullable DownloadInfo createDownloadInstance(String inputData) { + var client = clientFactory.get(); + + String project = null; + String version = null; + + var matcher = MODRINTH_ID_PATTERN.matcher(inputData); + if (matcher.matches()) { + project = matcher.group("project"); + version = matcher.group("version"); + } else { + matcher = MODRINTH_URL_PATTERN.matcher(inputData); + if (matcher.matches()) { + project = matcher.group("project"); + version = matcher.group("version"); + } + } + + if (version == null) { + LOGGER.error("Failed to parse Modrinth version from {}", inputData); + return null; + } + + URI uri; + + if (project != null) { + // https://docs.modrinth.com/#tag/versions/operation/getVersionFromIdOrNumber + uri = URI.create("%s/project/%s/version/%s".formatted(API_URL, project, version)); + } else { + // https://docs.modrinth.com/#tag/versions/operation/getVersion + uri = URI.create("%s/version/%s".formatted(API_URL, version)); + } + var request = addAuthHeader(new HttpGet(uri), cfg); + + try { + var modrinthVersion = client.execute(request, JsonResponseHandler.of(ModrinthVersionResponse.class)); + if (modrinthVersion == null) { + LOGGER.error("Received empty response for {}", request.getRequestUri()); + return null; + } + + var primaryFile = modrinthVersion.files().stream().filter(ModrinthVersionResponse.VersionFile::primary).findFirst().orElse(null); + if (primaryFile == null) { + LOGGER.error("No primary file found for modrinth version {}", modrinthVersion.id()); + return null; + } + + var hash = Optional.ofNullable(primaryFile.hashes().get("sha512")) + .map(raw -> Hash.fromString(Hash.Type.SHA512, raw)) + .orElse(null); + + // TODO parse and download required dependencies + // https://docs.modrinth.com/#tag/project_result_model + + return new ModrinthDownloadInfo(primaryFile.url(), primaryFile.filename(), hash, client, cfg); + + } catch (UncheckedIOException | IOException e) { + LOGGER.error("Failed to look up modrinth version {} at {}", version, request.getRequestUri(), e); + } + + return null; + } + + static T addAuthHeader(T request, ModrinthConfig cfg) { + cfg.accessToken().ifPresent(token -> request.addHeader("Authorization", token)); + return request; + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/VersionInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/VersionInfo.java new file mode 100644 index 0000000..a0da146 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/VersionInfo.java @@ -0,0 +1,4 @@ +package net.forgecraft.services.ember.app.mods.downloader.modrinth; + +public record VersionInfo(String projectId, String versionId, String fileName) { +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/api/ModrinthVersionResponse.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/api/ModrinthVersionResponse.java new file mode 100644 index 0000000..bdd7a42 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/modrinth/api/ModrinthVersionResponse.java @@ -0,0 +1,75 @@ +package net.forgecraft.services.ember.app.mods.downloader.modrinth.api; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +// https://docs.modrinth.com/#tag/project_result_model +public record ModrinthVersionResponse( + String name, + String version_number, + Optional changelog, + List dependencies, + List game_versions, + VersionType version_type, + List loaders, + boolean featured, + Optional status, + Optional requested_status, + String id, + String project_id, + String author_id, + String date_published, + long downloads, + List files +) { + public enum VersionType { + RELEASE, + BETA, + ALPHA + } + + public enum VersionStatus { + LISTED, + ARCHIVED, + DRAFT, + UNLISTED, + SCHEDULED, + UNKNOWN + } + + public record VersionDependency( + Optional version_id, + Optional project_id, + Optional file_name, + Type dependency_type + ) { + + public enum Type { + REQUIRED, + OPTIONAL, + INCOMPATIBLE, + EMBEDDED + } + } + + public record VersionFile( + Map hashes, + URI url, + String filename, + boolean primary, + long size, + Optional file_type + ) { + + public enum VersionFileType { + @JsonProperty("required-resource-pack") + REQUIRED_RESOURCE_PACK, + @JsonProperty("optional-resource-pack") + OPTIONAL_RESOURCE_PACK + } + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/plain/PlainUrlDownloader.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/plain/PlainUrlDownloader.java new file mode 100644 index 0000000..b4b8795 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/plain/PlainUrlDownloader.java @@ -0,0 +1,44 @@ +package net.forgecraft.services.ember.app.mods.downloader.plain; + +import net.forgecraft.services.ember.app.mods.downloader.DownloadInfo; +import net.forgecraft.services.ember.app.mods.downloader.Downloader; +import org.apache.hc.client5.http.classic.HttpClient; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.function.Supplier; + +public class PlainUrlDownloader implements Downloader { + + private static final Logger LOGGER = LoggerFactory.getLogger(PlainUrlDownloader.class); + private final Supplier clientFactory; + private final boolean allowInsecure; + + public PlainUrlDownloader(Supplier clientFactory, boolean allowInsecure) { + this.clientFactory = clientFactory; + this.allowInsecure = allowInsecure; + if (allowInsecure) { + LOGGER.warn("Downloading from insecure URLs is allowed."); + } + } + + @Override + public boolean isAcceptable(String inputData) { + //noinspection HttpUrlsUsage + return inputData.startsWith("https://") || (allowInsecure && inputData.startsWith("http://")); + } + + @Override + public @Nullable DownloadInfo createDownloadInstance(String inputData) { + try { + var uri = new URI(inputData); + return new SimpleDownloadInfo(uri, clientFactory.get()); + } catch (URISyntaxException e) { + LOGGER.error("Unable to download " + inputData, e); + } + return null; + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/downloader/plain/SimpleDownloadInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/plain/SimpleDownloadInfo.java new file mode 100644 index 0000000..b9fb19e --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/downloader/plain/SimpleDownloadInfo.java @@ -0,0 +1,133 @@ +package net.forgecraft.services.ember.app.mods.downloader.plain; + +import com.google.common.base.Preconditions; +import net.forgecraft.services.ember.app.mods.downloader.DownloadInfo; +import net.forgecraft.services.ember.app.mods.downloader.Hash; +import net.forgecraft.services.ember.util.Util; +import net.forgecraft.services.ember.util.serialization.ByteArrayResponseHandler; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +public class SimpleDownloadInfo implements DownloadInfo { + + private static final Logger LOGGER = LoggerFactory.getLogger(SimpleDownloadInfo.class); + private final URI url; + private Hash hashSha512; + private final String fileName; + @Nullable + private CompletableFuture download; + private final HttpClient client; + + public SimpleDownloadInfo(URI url, HttpClient client) { + this(url, extractFileNameFromUrl(url), null, client); + } + + public SimpleDownloadInfo(URI url, String fileName, @Nullable Hash expectedHash, HttpClient client) { + this.url = url; + this.fileName = fileName; + this.client = client; + setSha512(expectedHash); + } + + protected CompletableFuture startDownload() { + return CompletableFuture.runAsync(this::printStartMessage, Util.BACKGROUND_EXECUTOR) + .thenApplyAsync(aVoid -> { + try { + return client.execute(createRequest(), ByteArrayResponseHandler.INSTANCE); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + }) + .thenApply(bytes -> { + var calculatedHash = Hash.of(Hash.Type.SHA512, bytes); + + // verify hash matches if it exists + if (hashSha512 != null) { + if (!calculatedHash.equals(hashSha512)) { + throw new IllegalStateException("Hash mismatch for " + url + ": expected " + hashSha512 + ", got " + calculatedHash); + } + } else { + this.hashSha512 = calculatedHash; + } + return bytes; + }); + } + + protected void printStartMessage() { + LOGGER.debug("Downloading {}", url); + } + + protected HttpUriRequestBase createRequest() { + return new HttpGet(url); + } + + @Override + public URI getUrl() { + return url; + } + + @Override + public @Nullable Hash getSha512() { + return hashSha512; + } + + public void setSha512(@Nullable Hash hash) { + Preconditions.checkArgument(hash == null || hash.type() == Hash.Type.SHA512, "Hash type must be SHA-512"); + this.hashSha512 = hash; + } + + @Override + public boolean isComplete() { + return download != null && download.isDone(); + } + + @Override + public String getFileName() { + return fileName; + } + + @Override + public CompletableFuture getFileContents() { + if (download == null) { + start(); + } + + return download; + } + + @Override + public void start() { + this.download = startDownload(); + } + + @Override + public void cancel() { + if (download != null) { + download.cancel(true); + } + } + + @Nullable + protected static String extractFileNameFromUrl(URI url) { + var path = url.getPath(); + var lastSlash = path.lastIndexOf('/'); + if (lastSlash == -1) { + return path; + } else if (lastSlash == path.length() - 1) { + // not a file + return null; + } + + return path.substring(lastSlash + 1); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/parser/CommonModInfo.java b/src/main/java/net/forgecraft/services/ember/app/mods/parser/CommonModInfo.java new file mode 100644 index 0000000..e607ed6 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/parser/CommonModInfo.java @@ -0,0 +1,18 @@ +package net.forgecraft.services.ember.app.mods.parser; + +import java.util.Optional; + +public record CommonModInfo( + Type type, + String id, + String name, + String version, + Optional projectUrl, + Optional issuesUrl +) { + public enum Type { + NEOFORGE, + FABRIC, + QUILT + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/parser/FabricModJson.java b/src/main/java/net/forgecraft/services/ember/app/mods/parser/FabricModJson.java new file mode 100644 index 0000000..97bae69 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/parser/FabricModJson.java @@ -0,0 +1,128 @@ +package net.forgecraft.services.ember.app.mods.parser; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.ValueNode; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.google.common.base.Preconditions; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +// https://fabricmc.net/wiki/documentation:fabric_mod_json_spec +@JsonIgnoreProperties(ignoreUnknown = true) +public record FabricModJson( + int schemaVersion, + String id, + String version, + Optional name, + Optional description, + Optional> authors, + Optional> contributors, + Optional contact, + + // can be String or String[] +// Optional license, + Optional icon, + Optional environment, + // entrypoints + // mixins + // dependencies etc. + + Optional> custom +) { + + @JsonSerialize(using = Person.Serializer.class) + @JsonDeserialize(using = Person.Deserializer.class) + public record Person(String name, Optional contact) { + + public static class Serializer extends StdSerializer { + + public Serializer() { + this(null); + } + + public Serializer(Class t) { + super(t); + } + + @Override + public void serialize(Person value, JsonGenerator gen, SerializerProvider provider) throws IOException { + if (value.contact().isPresent()) { + gen.writeStartObject(); + gen.writeStringField("name", value.name()); + gen.writePOJOField("contact", value.contact().get()); + } else { + gen.writeString(value.name()); + } + } + } + + public static class Deserializer extends StdDeserializer { + + public Deserializer() { + this(null); + } + + public Deserializer(Class vc) { + super(vc); + } + + @Override + public Person deserialize(JsonParser parser, DeserializationContext ctx) throws IOException { + var data = parser.getCodec().readTree(parser); + if (data.isValueNode()) { + var name = ((ValueNode) data).textValue(); + return new Person(name, Optional.empty()); + } else { + var name = ((ValueNode) data.get("name")).textValue(); + Optional contact; + if (data.get("contact") instanceof JsonNode node) { + var typeRef = new TypeReference>() { + }; + contact = parser.getCodec().readValue(node.traverse(), typeRef); + } else { + contact = Optional.empty(); + } + + return new Person(name, contact); + } + } + } + } + + public record ContactInfo( + Optional email, + Optional irc, + Optional homepage, + Optional issues, + Optional sources, + @JsonAnySetter + Map _other + ) { + } + + public void validate() { + Preconditions.checkNotNull(id, "id must not be null"); + Preconditions.checkNotNull(version, "version must not be null"); + + Preconditions.checkState(schemaVersion == 1, "Unsupported schema version " + schemaVersion); + Preconditions.checkState(id.matches("^[a-z][a-z0-9-_]{1,63}$"), "Invalid mod id " + id); + } + + public CommonModInfo asCommonModInfo() { + return new CommonModInfo(CommonModInfo.Type.FABRIC, id(), name().orElse(id()), version(), contact().flatMap(ContactInfo::homepage), contact().flatMap(ContactInfo::issues)); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/app/mods/parser/ModInfoParser.java b/src/main/java/net/forgecraft/services/ember/app/mods/parser/ModInfoParser.java new file mode 100644 index 0000000..154faca --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/app/mods/parser/ModInfoParser.java @@ -0,0 +1,129 @@ +package net.forgecraft.services.ember.app.mods.parser; + +import com.electronwill.nightconfig.core.CommentedConfig; +import com.fasterxml.jackson.core.type.TypeReference; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import net.forgecraft.services.ember.util.Util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +/** + * Takes in a mod of any form and attempts to parse it's metadata by either reading it's mods.toml file or from it's + * *mod.json file. + *

+ * // TODO: Maybe have special support for server plugins? + */ +public class ModInfoParser { + private static final Logger LOGGER = LoggerFactory.getLogger(ModInfoParser.class); + + public ModInfoParser() { + } + + /** + * Attempt to parse the mod's metadata + * + * @return The mod's metadata + */ + public static List parse(Path modPath) throws IOException { + + // Create a file system for the jar + try (FileSystem system = FileSystems.newFileSystem(modPath)) { + var modsToml = system.getPath("META-INF/mods.toml"); + if (Files.exists(modsToml)) { + try (var reader = Files.newBufferedReader(modsToml)) { + return parseModsToml(reader); + } + } + // NeoForge: neoforge.mods.toml + modsToml = system.getPath("META-INF/neoforge.mods.toml"); + if (Files.exists(modsToml)) { + try (var reader = Files.newBufferedReader(modsToml)) { + return parseModsToml(reader); + } + } + + var quiltModJson = system.getPath("quilt.mod.json"); + if (Files.exists(quiltModJson)) { + LOGGER.error("Quilt mod.json found, but not supported yet"); + + //TODO parse QMJ + } + + var fabricModJson = system.getPath("fabric.mod.json"); + if (Files.exists(fabricModJson)) { + try (var reader = Files.newBufferedReader(fabricModJson)) { + return parseFabricModJson(reader); + } + } + } + + return List.of(); + } + + private static List parseModsToml(Reader reader) throws RuntimeException { + var builder = ImmutableList.builder(); + + //TODO parse LexForge mods, right now this assumes every mods.toml is a valid NeoForge mod + + CommentedConfig cfgRoot = Util.TOML_PARSER.parse(reader); + + // global properties + Optional issueTrackerUrl = Optional.ofNullable(cfgRoot.get("issueTrackerURL")); + + + List mods = cfgRoot.get("mods"); + if (mods != null) { + for (CommentedConfig mod : mods) { + String modId = mod.get("modId"); // on older versions, this is the only mandatory field + Preconditions.checkNotNull(modId, "Mod id is missing"); + Preconditions.checkState(modId.matches("^[a-z][a-z0-9_]{1,63}$"), "Invalid mod id " + modId); + + String modVersion = mod.get("version"); + if (modVersion == null) { + modVersion = "1"; + } + + String displayName = mod.get("displayName"); + if (displayName == null) { + displayName = modId; + } + + Optional modUrl = Optional.ofNullable(mod.get("modUrl")); + Optional displayUrl = Optional.ofNullable(mod.get("displayURL")); + + builder.add(new CommonModInfo(CommonModInfo.Type.NEOFORGE, modId, displayName, modVersion, modUrl.isPresent() ? modUrl : displayUrl, issueTrackerUrl)); + } + } + + return builder.build(); + } + + private static List parseFabricModJson(Reader reader) throws RuntimeException, IOException { + var builder = ImmutableList.builder(); + + var data = Util.JACKSON_MAPPER.readTree(reader); + + if (data.isArray()) { + var typeRef = new TypeReference>() { + }; + var list = Util.JACKSON_MAPPER.treeToValue(data, typeRef); + list.stream().peek(FabricModJson::validate).map(FabricModJson::asCommonModInfo).forEachOrdered(builder::add); + } else { + var fmj = Util.JACKSON_MAPPER.treeToValue(data, FabricModJson.class); + fmj.validate(); + builder.add(fmj.asCommonModInfo()); + } + + return builder.build(); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/bot/listener/ModApprovalListener.java b/src/main/java/net/forgecraft/services/ember/bot/listener/ModApprovalListener.java new file mode 100644 index 0000000..f04cfaa --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/bot/listener/ModApprovalListener.java @@ -0,0 +1,76 @@ +package net.forgecraft.services.ember.bot.listener; + +import it.unimi.dsi.fastutil.longs.Long2ObjectArrayMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.LongSet; +import net.forgecraft.services.ember.app.config.Config; +import net.forgecraft.services.ember.app.config.GeneralConfig; +import net.forgecraft.services.ember.app.config.MinecraftServerConfig; +import net.forgecraft.services.ember.app.mods.ModFileManager; +import org.javacord.api.entity.message.MessageType; +import org.javacord.api.event.message.reaction.ReactionAddEvent; +import org.javacord.api.listener.message.reaction.ReactionAddListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +public class ModApprovalListener implements ReactionAddListener { + + private static final List APPROVAL_REACTION = List.of("👍", "👍🏻", "👍🏼", "👍🏽", "👍🏾", "👍🏿"); + + private static final Logger LOGGER = LoggerFactory.getLogger(ModApprovalListener.class); + + private final GeneralConfig cfg; + private final LongSet adminRoles; + private final Long2ObjectMap minecraftServers = new Long2ObjectArrayMap<>(); + + public ModApprovalListener(Config config) { + this.cfg = config.getGeneral(); + this.adminRoles = LongSet.of(config.getDiscord().adminRoles()); + + for (MinecraftServerConfig server : config.getMinecraftServers()) { + minecraftServers.put(server.uploadChannel(), server); + } + } + + @Override + public void onReactionAdd(ReactionAddEvent event) { + var reaction = event.requestReaction().join().orElse(null); + if (reaction == null) { + // reaction removed before we got to process it + return; + } + + var msg = reaction.getMessage(); + + if (!msg.isServerMessage() || msg.getType() != MessageType.NORMAL) { + return; + } + var server = msg.getServer().orElseThrow(); + + var emoji = reaction.getEmoji(); + + if (!emoji.asUnicodeEmoji().map(APPROVAL_REACTION::contains).orElse(false)) { + // not an approval reaction + return; + } + + var serverCfg = minecraftServers.get(msg.getChannel().getId()); + if (serverCfg == null) { + // don't know this channel, ignore message + return; + } + + var user = event.requestUser().join(); + var isAdmin = user.getRoles(server).stream().anyMatch(role -> adminRoles.contains(role.getId())); + if (isAdmin) { + LOGGER.debug("Handling approval for message {} by user {}", msg.getId(), user.getId()); + ModFileManager.handleApproval(user.getId(), msg.getId(), cfg, serverCfg) + .exceptionally(ex -> { + LOGGER.error("Failed to handle approval", ex); + return null; + }); + } + } +} diff --git a/src/main/java/net/forgecraft/services/ember/bot/listener/ModUploadListener.java b/src/main/java/net/forgecraft/services/ember/bot/listener/ModUploadListener.java new file mode 100644 index 0000000..4fa46e1 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/bot/listener/ModUploadListener.java @@ -0,0 +1,139 @@ +package net.forgecraft.services.ember.bot.listener; + +import it.unimi.dsi.fastutil.Pair; +import it.unimi.dsi.fastutil.longs.Long2ObjectArrayMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import net.forgecraft.services.ember.app.config.Config; +import net.forgecraft.services.ember.app.config.GeneralConfig; +import net.forgecraft.services.ember.app.config.MinecraftServerConfig; +import net.forgecraft.services.ember.app.mods.ModFileManager; +import net.forgecraft.services.ember.app.mods.downloader.DownloadHelper; +import net.forgecraft.services.ember.app.mods.downloader.DownloadInfo; +import net.forgecraft.services.ember.app.mods.downloader.DownloaderFactory; +import net.forgecraft.services.ember.util.Util; +import org.javacord.api.entity.message.MessageAttachment; +import org.javacord.api.entity.message.MessageType; +import org.javacord.api.event.message.MessageCreateEvent; +import org.javacord.api.listener.message.MessageCreateListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; + +public class ModUploadListener implements MessageCreateListener { + + private static final String STATUS_SUCCESS = "✅"; + private static final String STATUS_PROCESSING = "⌛"; + private static final String STATUS_INVALID_MESSAGE = "❌"; + private static final String STATUS_ERROR = "⚠"; + + private static final Logger LOGGER = LoggerFactory.getLogger(ModUploadListener.class); + private final GeneralConfig cfg; + private final Long2ObjectMap minecraftServers = new Long2ObjectArrayMap<>(); + + public ModUploadListener(Config cfg) { + this.cfg = cfg.getGeneral(); + for (MinecraftServerConfig server : cfg.getMinecraftServers()) { + minecraftServers.put(server.uploadChannel(), server); + } + } + + @Override + public void onMessageCreate(MessageCreateEvent event) { + var msg = event.getMessage(); + + // only listen to messages sent by users + if (msg.getType() != MessageType.NORMAL) { + return; + } + + var serverCfg = minecraftServers.get(msg.getChannel().getId()); + if (serverCfg == null) { + // don't know this channel, ignore message + return; + } + + msg.addReaction(STATUS_PROCESSING); + + // analyze the message and search for download targets + CompletableFuture.runAsync(() -> { + + List downloads = new ArrayList<>(); + + // simplest case first: are there file attachments? + for (MessageAttachment attachment : event.getMessage().getAttachments()) { + if (attachment.getFileName().endsWith(".jar")) { + var download = DownloaderFactory.INSTANCE.tryDownload(attachment.getUrl().toString()); + + if (download != null) { + downloads.add(download); + } + } + } + + // next: test each line for being a valid download target + if (!msg.getContent().isEmpty()) { + msg.getContent().lines() + // split on whitespace in case there are multiple targets on one line + .flatMap(line -> Arrays.stream(line.split("\\s"))) + .filter(Predicate.not(String::isBlank)) + // surrounding a URL with <> causes it to not embed, so we special-case this to make it still parse as valid URL later + .map(s -> s.replaceAll("^<(.*)>$", "$1")) + .map(DownloaderFactory.INSTANCE::tryDownload) + .filter(Objects::nonNull) + .forEach(downloads::add); + } + + if (downloads.isEmpty()) { + // unable to find any download target, cancel further processing + msg.addReaction(STATUS_INVALID_MESSAGE) + .thenCompose(aVoid -> msg.removeOwnReactionByEmoji(STATUS_PROCESSING)).join(); + return; + } + + var uploader = msg.getAuthor().asUser().orElseThrow(); + + AtomicBoolean errored = new AtomicBoolean(false); + + CompletableFuture.allOf(downloads.stream().map(download -> download.getFileContents() + .thenApplyAsync(bytes -> { + var hash = Objects.requireNonNull(download.getSha512()); + try { + var target = DownloadHelper.getCacheFileName(cfg, serverCfg, hash, download.getFileName()); + DownloadHelper.saveTo(target, bytes); + return Pair.of(hash, target); + } catch (IOException e) { + throw new UncheckedIOException("error saving file", e); + } + }, Util.BACKGROUND_EXECUTOR) + .thenCompose(data -> ModFileManager.handleDownload(data.left(), data.right(), uploader, msg.getId())) + .exceptionally(ex -> { + errored.set(true); + // omit stacktrace for this case + if (ex instanceof FileAlreadyExistsException) { + LOGGER.error("File already exists: {}", ex.getMessage()); + return null; + } + + LOGGER.error("Download error on {}", download.getUrl(), ex); + return null; + })).toArray(CompletableFuture[]::new)) + .thenCompose(aVoid -> { + if (!errored.get()) { + return msg.addReaction(STATUS_SUCCESS); + } else { + return msg.addReaction(STATUS_ERROR); + } + }).thenCompose(aVoid -> msg.removeOwnReactionByEmoji(STATUS_PROCESSING)); + }, Util.BACKGROUND_EXECUTOR); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/helpers/ArgsParser.java b/src/main/java/net/forgecraft/services/ember/helpers/ArgsParser.java deleted file mode 100644 index 844bf09..0000000 --- a/src/main/java/net/forgecraft/services/ember/helpers/ArgsParser.java +++ /dev/null @@ -1,71 +0,0 @@ -package net.forgecraft.services.ember.helpers; - -import java.util.HashMap; -import java.util.Map; - -/** - * Parses command line arguments. - * I wrote this before seeing apache commons cli, so I would probably use that instead if I were to write this again. - */ -public class ArgsParser { - private final Map args; - - private ArgsParser(String[] args) { - this.args = this.parseArgs(args); - } - - public static ArgsParser parse(String[] args) { - return new ArgsParser(args); - } - - public String get(String key) { - return args.get(key); - } - - public String getOrThrow(String key) { - String value = args.get(key); - if (value == null) { - throw new IllegalArgumentException("Argument " + key + " is required"); - } - return value; - } - - public Map getArgs() { - return args; - } - - /** - * Relative simple parser that creates a key-value map from the command line arguments. - * Example input: --debug --test=123 --test2 value - * @param args The command line arguments - * @return A map of key-value pairs - */ - public Map parseArgs(String[] args) { - Map parsedArguments = new HashMap<>(); - - String currentKey = null; - - for (String arg : args) { - if (arg.startsWith("--")) { - // Argument is of the form "--flag" or "--flag=value" - int equalsIndex = arg.indexOf('='); - if (equalsIndex != -1) { - // Argument is of the form "--flag=value" - currentKey = arg.substring(2, equalsIndex); - String value = arg.substring(equalsIndex + 1); - parsedArguments.put(currentKey, value); - } else { - // Argument is of the form "--flag" - currentKey = arg.substring(2); - parsedArguments.put(currentKey, null); - } - } else if (currentKey != null) { - // Argument is of the form "--flag value" - parsedArguments.put(currentKey, arg); - currentKey = null; - } - } - - return parsedArguments; - } -} diff --git a/src/main/java/net/forgecraft/services/ember/util/ProjectInfo.java b/src/main/java/net/forgecraft/services/ember/util/ProjectInfo.java new file mode 100644 index 0000000..aae80ab --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/util/ProjectInfo.java @@ -0,0 +1,41 @@ +package net.forgecraft.services.ember.util; + +import java.net.URI; +import java.util.Objects; + +public class ProjectInfo { + + private static final String NAME = "Ember"; + private final String version = Objects.requireNonNullElse(getClass().getPackage().getImplementationVersion(), "development"); + private final URI url = URI.create("https://github.com/forgecraft/Ember"); + + private String userAgentString; + + public static final ProjectInfo INSTANCE = new ProjectInfo(); + + private ProjectInfo() { + } + + public String getName() { + return NAME; + } + + public String getVersion() { + return version; + } + + public URI getUrl() { + return url; + } + + /** + * @return a User-Agent string + */ + public String asUserAgentString() { + if(userAgentString == null) { + userAgentString = "%s/%s (%s)".formatted(getName(), getVersion(), getUrl()); + } + + return userAgentString; + } +} diff --git a/src/main/java/net/forgecraft/services/ember/util/Util.java b/src/main/java/net/forgecraft/services/ember/util/Util.java new file mode 100644 index 0000000..1defa52 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/util/Util.java @@ -0,0 +1,72 @@ +package net.forgecraft.services.ember.util; + +import com.electronwill.nightconfig.toml.TomlParser; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import net.forgecraft.services.ember.Main; +import net.forgecraft.services.ember.app.Services; +import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class Util { + + // TODO move to config + /** + * A list of known maven repositories to try and resolve maven artifacts against + */ + @Deprecated + public static final List KNOWN_MAVENS = List.of( + "https://api.modrinth.com/maven", + "https://cursemaven.com", + "https://maven.blamejared.com", + "https://modmaven.k-4u.nl", + "https://maven.saps.dev/releases", + "https://maven.saps.dev/snapshots", + "https://maven.nanite.dev/releases", + "https://maven.nanite.dev/snapshots", + "https://maven.fabricmc.net", + "https://maven.creeperhost.net", + "https://maven.minecraftforge.net", + "https://maven.neoforged.net/releases", + "https://repo.spongepowered.org/maven", + "https://maven.uuid.gg/releases" + ); + + public static CloseableHttpClient newHttpClient() { + return HttpClients.custom() + .setRedirectStrategy(DefaultRedirectStrategy.INSTANCE) + .setUserAgent(ProjectInfo.INSTANCE.asUserAgentString()) + .build(); + } + + public static String stripTrailingSlash(String input) { + if (input.endsWith("/")) { + return input.substring(0, input.length() - 1); + } + return input; + } + + public static Services services() { + return Main.INSTANCE.services(); + } + + public static final ExecutorService BACKGROUND_EXECUTOR = Executors.newCachedThreadPool(r -> new Thread(r, "Ember Background Worker")); + + public static final ObjectMapper JACKSON_MAPPER = JsonMapper.builder() + .findAndAddModules() + .serializationInclusion(JsonInclude.Include.NON_ABSENT) + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + + public static final TomlParser TOML_PARSER = new TomlParser(); + + // system properties + public static final boolean OPT_ALLOW_INSECURE_DOWNLOADS = System.getenv("ALLOW_INSECURE_DOWNLOADS") != null && !"false".equalsIgnoreCase(System.getenv("ALLOW_INSECURE_DOWNLOADS")); +} diff --git a/src/main/java/net/forgecraft/services/ember/util/serialization/ByteArrayResponseHandler.java b/src/main/java/net/forgecraft/services/ember/util/serialization/ByteArrayResponseHandler.java new file mode 100644 index 0000000..ad3f3aa --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/util/serialization/ByteArrayResponseHandler.java @@ -0,0 +1,21 @@ +package net.forgecraft.services.ember.util.serialization; + +import org.apache.hc.client5.http.impl.classic.AbstractHttpClientResponseHandler; +import org.apache.hc.core5.http.HttpEntity; + +import java.io.IOException; + +public class ByteArrayResponseHandler extends AbstractHttpClientResponseHandler { + + public static final ByteArrayResponseHandler INSTANCE = new ByteArrayResponseHandler(); + + private ByteArrayResponseHandler() { + } + + @Override + public byte[] handleEntity(HttpEntity entity) throws IOException { + try (var stream = entity.getContent()) { + return stream.readAllBytes(); + } + } +} diff --git a/src/main/java/net/forgecraft/services/ember/util/serialization/JsonResponseHandler.java b/src/main/java/net/forgecraft/services/ember/util/serialization/JsonResponseHandler.java new file mode 100644 index 0000000..ef55195 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/util/serialization/JsonResponseHandler.java @@ -0,0 +1,44 @@ +package net.forgecraft.services.ember.util.serialization; + +import com.fasterxml.jackson.core.exc.StreamReadException; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.apache.hc.client5.http.impl.classic.AbstractHttpClientResponseHandler; +import org.apache.hc.core5.http.HttpEntity; + +import java.io.IOException; + +public class JsonResponseHandler extends AbstractHttpClientResponseHandler { + + private final Class type; + private final ObjectMapper mapper; + + private JsonResponseHandler(Class type, ObjectMapper mapper) { + this.type = type; + this.mapper = mapper; + } + + @Override + public T handleEntity(HttpEntity entity) throws IOException { + try (var stream = entity.getContent()) { + return mapper.readValue(stream, type); + } catch (StreamReadException | DatabindException ex) { + throw new IOException(ex); + } + } + + public static JsonResponseHandler of(Class type, ObjectMapper mapper) { + return new JsonResponseHandler<>(type, mapper); + } + + public static JsonResponseHandler of(Class type) { + return of(type, JsonMapper.builder() + .findAndAddModules() + .enable(SerializationFeature.INDENT_OUTPUT) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build() + ); + } +} diff --git a/src/main/java/net/forgecraft/services/ember/util/serialization/StatusCodeOnlyResponseHandler.java b/src/main/java/net/forgecraft/services/ember/util/serialization/StatusCodeOnlyResponseHandler.java new file mode 100644 index 0000000..bb3d7d4 --- /dev/null +++ b/src/main/java/net/forgecraft/services/ember/util/serialization/StatusCodeOnlyResponseHandler.java @@ -0,0 +1,17 @@ +package net.forgecraft.services.ember.util.serialization; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; + +public class StatusCodeOnlyResponseHandler implements HttpClientResponseHandler { + + public static final StatusCodeOnlyResponseHandler INSTANCE = new StatusCodeOnlyResponseHandler(); + + private StatusCodeOnlyResponseHandler() { + } + + @Override + public Integer handleResponse(ClassicHttpResponse response) { + return response.getCode(); + } +} diff --git a/src/test/java/net/forgecraft/services/ember/app/ConfigTest.java b/src/test/java/net/forgecraft/services/ember/app/ConfigTest.java index 283205a..7c8ffb7 100644 --- a/src/test/java/net/forgecraft/services/ember/app/ConfigTest.java +++ b/src/test/java/net/forgecraft/services/ember/app/ConfigTest.java @@ -1,51 +1,57 @@ package net.forgecraft.services.ember.app; +import net.forgecraft.services.ember.app.config.Config; +import net.forgecraft.services.ember.app.config.DiscordConfig; +import net.forgecraft.services.ember.app.config.ModrinthConfig; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; public class ConfigTest { @Test - public void configLoadAndWritesDefault() { - var config = new Config("tests/config.json"); + public void configWritesDefault() { + var configPath = Path.of("tests/config.json"); - assertNotNull(config); - assertNotNull(config.getDiscord()); + Config.load(configPath); - assertEquals("YOUR_DISCORD", config.getDiscord().token()); + assertTrue(Files.exists(configPath)); } @Test - public void readsConfigCorrectly() { + public void defaultConfigParsesCorrectly() { + var configPath = Path.of("tests/config.json"); // Write the default config - new Config("tests/config.json"); + Config.load(configPath); // Read the config - var config = new Config("tests/config.json"); + var config = Config.load(configPath); assertNotNull(config); assertNotNull(config.getDiscord()); + assertNotNull(config.getModrinth()); - assertEquals("YOUR_DISCORD", config.getDiscord().token()); + assertEquals(DiscordConfig.create(), config.getDiscord()); + assertEquals(ModrinthConfig.create(), config.getModrinth()); } @BeforeAll public static void setup() throws IOException { // Setup the test folder - var testFolder = Path.of("./tests"); - FileUtils.createParentDirectories(testFolder.toFile()); + var testFolder = Path.of("tests"); + Files.createDirectories(testFolder); } @AfterAll public static void cleanup() throws IOException { // Cleanup the test folder - var testFolder = Path.of("./tests"); + var testFolder = Path.of("tests"); FileUtils.deleteDirectory(testFolder.toFile()); } } diff --git a/src/test/java/net/forgecraft/services/ember/helpers/ArgsParserTest.java b/src/test/java/net/forgecraft/services/ember/helpers/ArgsParserTest.java deleted file mode 100644 index 663fad4..0000000 --- a/src/test/java/net/forgecraft/services/ember/helpers/ArgsParserTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.forgecraft.services.ember.helpers; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -public class ArgsParserTest { - @Test - public void parsesFlagWithoutValue() { - var parser = ArgsParser.parse(new String[] { "--debug" }); - assertNull(parser.get("debug")); - } - - @Test - public void parsesFlagWithValue() { - var parser = ArgsParser.parse(new String[] { "--test=123" }); - assertEquals("123", parser.get("test")); - } - - @Test - public void parsesMultipleFlags() { - var parser = ArgsParser.parse(new String[] { "--debug", "--test=123" }); - assertNull(parser.get("debug")); - assertEquals("123", parser.get("test")); - } - - @Test - public void ignoresNonFlagArguments() { - var parser = ArgsParser.parse(new String[] { "ignoreme", "--debug" }); - assertNull(parser.get("debug")); - assertNull(parser.get("ignoreme")); - } - - @Test - public void throwsExceptionForMissingRequiredFlag() { - var parser = ArgsParser.parse(new String[] { "--debug" }); - assertThrows(IllegalArgumentException.class, () -> parser.getOrThrow("test")); - } -} diff --git a/src/test/java/net/forgecraft/services/ember/mods/downloader/DownloaderResolveTest.java b/src/test/java/net/forgecraft/services/ember/mods/downloader/DownloaderResolveTest.java index 9cd501b..ca34b69 100644 --- a/src/test/java/net/forgecraft/services/ember/mods/downloader/DownloaderResolveTest.java +++ b/src/test/java/net/forgecraft/services/ember/mods/downloader/DownloaderResolveTest.java @@ -1,9 +1,9 @@ package net.forgecraft.services.ember.mods.downloader; -import net.forgecraft.services.ember.app.mods.downloader.CurseForgeDownloader; +import net.forgecraft.services.ember.app.mods.downloader.curseforge.CurseForgeDownloader; import net.forgecraft.services.ember.app.mods.downloader.DownloaderFactory; -import net.forgecraft.services.ember.app.mods.downloader.MavenDownloader; -import net.forgecraft.services.ember.app.mods.downloader.ModrinthDownloader; +import net.forgecraft.services.ember.app.mods.downloader.maven.MavenDownloader; +import net.forgecraft.services.ember.app.mods.downloader.modrinth.ModrinthDownloader; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/net/forgecraft/services/ember/mods/downloader/MavenDownloaderTest.java b/src/test/java/net/forgecraft/services/ember/mods/downloader/MavenDownloaderTest.java index 50ee25f..97c065f 100644 --- a/src/test/java/net/forgecraft/services/ember/mods/downloader/MavenDownloaderTest.java +++ b/src/test/java/net/forgecraft/services/ember/mods/downloader/MavenDownloaderTest.java @@ -1,7 +1,7 @@ package net.forgecraft.services.ember.mods.downloader; import net.forgecraft.services.ember.app.mods.downloader.DownloaderFactory; -import net.forgecraft.services.ember.app.mods.downloader.MavenDownloader; +import net.forgecraft.services.ember.app.mods.downloader.maven.MavenDownloader; import org.junit.jupiter.api.Test; import java.util.List; @@ -27,7 +27,7 @@ public void resolvesToKnownTrustedMaven() { assertInstanceOf(MavenDownloader.class, downloader); - var download = downloader.download(joinedInputData); + var download = downloader.createDownloadInstance(joinedInputData); assertNotNull(download); } } diff --git a/src/test/java/net/forgecraft/services/ember/mods/downloader/ModInfoParserTest.java b/src/test/java/net/forgecraft/services/ember/mods/downloader/ModInfoParserTest.java index 0c02147..58c28fd 100644 --- a/src/test/java/net/forgecraft/services/ember/mods/downloader/ModInfoParserTest.java +++ b/src/test/java/net/forgecraft/services/ember/mods/downloader/ModInfoParserTest.java @@ -1,36 +1,64 @@ package net.forgecraft.services.ember.mods.downloader; -import net.forgecraft.services.ember.app.mods.ModInfoParser; +import com.google.common.collect.ImmutableMap; +import net.forgecraft.services.ember.app.mods.parser.ModInfoParser; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.net.http.HttpClient; +import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; public class ModInfoParserTest { + + @BeforeAll + public static void setup() throws Exception { + + Files.createDirectories(Path.of("downloads")); + + var files = ImmutableMap.builder() + .put("https://maven.blamejared.com/mezz/jei/jei-1.19.2-forge/11.2.0.246/jei-1.19.2-forge-11.2.0.246.jar", "downloads/jei-1.19.2-forge-11.2.0.246.jar") + .put("https://maven.blamejared.com/mezz/jei/jei-1.19.2-fabric/11.2.0.246/jei-1.19.2-fabric-11.2.0.246.jar", "downloads/jei-1.19.2-fabric-11.2.0.246.jar") + .build(); + + try (var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { + files.forEach((url, path) -> { + var request = java.net.http.HttpRequest.newBuilder().uri(java.net.URI.create(url)).build(); + try { + client.send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(Path.of(path))); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException("Failed to download file", e); + } + }); + } + } + @Test void canParseValidForgeMod() { - // TODO: Fix this location to something non-absolute - var pathToMod = Path.of("./downloads/jei-1.19.1-forge-11.2.0.244.jar"); - var parser = new ModInfoParser(pathToMod); + var pathToMod = Path.of("downloads/jei-1.19.2-forge-11.2.0.246.jar"); + var info = assertDoesNotThrow(() -> ModInfoParser.parse(pathToMod)); - var info = assertDoesNotThrow(parser::parse); + assertFalse(info.isEmpty(), "No mod info found"); + var first = info.getFirst(); - // Not all mods will have the same values for both forge and fabric! - assertEquals("jei", info.id()); - assertEquals("Just Enough Items", info.name()); - assertEquals("11.2.0.244", info.version()); + assertEquals("jei", first.id()); + assertEquals("Just Enough Items", first.name()); + assertEquals("11.2.0.246", first.version()); } @Test void canParseValidFabricMod() { - // TODO: Fix this location to something non-absolute - var pathToMod = Path.of("./downloads/jei-1.19.1-fabric-11.2.0.244.jar"); - var parser = new ModInfoParser(pathToMod); - - var info = assertDoesNotThrow(parser::parse); - assertEquals("jei", info.id()); - assertEquals("Just Enough Items", info.name()); - assertEquals("11.2.0.244", info.version()); + var pathToMod = Path.of("downloads/jei-1.19.2-fabric-11.2.0.246.jar"); + var info = assertDoesNotThrow(() -> ModInfoParser.parse(pathToMod)); + + assertFalse(info.isEmpty(), "No mod info found"); + var first = info.getFirst(); + + assertEquals("jei", first.id()); + assertEquals("Just Enough Items", first.name()); + assertEquals("11.2.0.246", first.version()); } }