diff --git a/.gitignore b/.gitignore index c3ccfb3..d1ddb4f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ bin/ ### Mac OS ### .DS_Store +### Linux ### +.directory + ### Custom /.idea /run diff --git a/build.gradle.kts b/build.gradle.kts index 0171bdc..40accaa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.gradle.ext.Application import org.jetbrains.gradle.ext.runConfigurations import org.jetbrains.gradle.ext.settings @@ -14,7 +13,7 @@ group = "net.modgarden" version = project.properties["version"].toString() java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) withJavadocJar() } @@ -30,13 +29,13 @@ dependencies { implementation(libs.javalin) implementation(libs.logback) implementation(libs.sqlite) - implementation(libs.snowflakeid) implementation(libs.dotenv) implementation(libs.jwt.api) implementation(libs.jwt.impl) implementation(libs.jwt.gson) - implementation(libs.base62) implementation(libs.jetbrains.annotations) + + implementation(libs.argon2.jvm) } tasks { @@ -81,18 +80,23 @@ application { mainClass = "net.modgarden.backend.ModGardenBackend" } +// When refreshing the project, the entire build.gradle.kts may visually error because of IDEA Ext. +// Refresh the project again to fix this. We'll likely have to report this to JetBrains. idea { - project { - settings.runConfigurations { - create("Run", Application::class.java) { - workingDirectory = "${rootProject.projectDir}/run" - mainClass = "net.modgarden.backend.ModGardenBackend" - moduleName = project.idea.module.name + ".main" - includeProvidedDependencies = true - envs = mapOf( - "env" to "development" - ) - } - } - } + project { + settings { + runConfigurations { + create("Run Backend", org.jetbrains.gradle.ext.Application::class.java) { + workingDirectory = "${rootProject.projectDir}/run" + mainClass = "net.modgarden.backend.ModGardenBackend" + moduleName = project.idea.module.name + ".main" + includeProvidedDependencies = true + envs = mapOf( + "env" to "development", + ) + jvmArgs = "--enable-native-access=ALL-UNNAMED" + } + } + } + } } diff --git a/gradle.properties b/gradle.properties index 749ca75..7c21613 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version = 1.4.0 +version = 2.0.0-beta.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e935d12..31239c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] dfu = "8.0.16" -javalin = "6.4.0" -logback = "1.5.18" -sqlite = "3.47.1.0" -snowflakeid = "0.0.2" +javalin = "6.7.0" +logback = "1.5.21" +sqlite = "3.51.0.0" dotenv = "2.3.0" jwt = "0.11.5" -base62 = "0.1.3" jetbrains_annotations = "0.1.3" -idea_ext = "1.1.9" +argon2-jvm = "2.12" + +idea_ext = "1.3" [libraries] dfu = { group = "com.mojang", name = "datafixerupper", version.ref = "dfu" } @@ -17,12 +17,12 @@ javalin = { group = "io.javalin", name = "javalin", version.ref = "javalin" } logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } sqlite = { group = "org.xerial", name = "sqlite-jdbc", version.ref = "sqlite" } dotenv = { group = "io.github.cdimascio", name = "dotenv-java", version.ref = "dotenv" } -snowflakeid = { group = "de.mkammerer.snowflake-id", name = "snowflake-id", version.ref = "snowflakeid" } jwt_api = { group = "io.jsonwebtoken", name = "jjwt-api", version.ref = "jwt" } jwt_impl = { group = "io.jsonwebtoken", name = "jjwt-impl", version.ref = "jwt" } jwt_gson = { group = "io.jsonwebtoken", name = "jjwt-gson", version.ref = "jwt" } -base62 = { group = "io.seruco.encoding", name = "base62", version.ref = "base62" } jetbrains_annotations = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains_annotations" } +argon2-jvm = { group = "de.mkammerer", name = "argon2-jvm", version.ref = "argon2-jvm" } + [plugins] idea_ext = { id = "org.jetbrains.gradle.plugin.idea-ext", version.ref = "idea_ext" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5c82cb0..d706aba 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/net/modgarden/backend/HypertextResult.java b/src/main/java/net/modgarden/backend/HypertextResult.java new file mode 100644 index 0000000..a0dd139 --- /dev/null +++ b/src/main/java/net/modgarden/backend/HypertextResult.java @@ -0,0 +1,52 @@ +package net.modgarden.backend; + +import io.javalin.http.Context; +import org.jetbrains.annotations.Nullable; + +public final class HypertextResult { + private final boolean success; + private final int status; + private String message; + private T object; + + public HypertextResult(int status, String message) { + this.success = false; + this.status = status; + this.message = message; + } + + public HypertextResult(T object) { + this.success = true; + this.status = 200; + this.object = object; + } + + public boolean isSuccess() { + return success; + } + + public int getStatus() { + return status; + } + + public String getMessage() { + if (success) throw new IllegalStateException("result succeeded"); + return message; + } + + public T getObject() { + if (!success) throw new IllegalStateException("result failed"); + return object; + } + + @Nullable + public T unwrap(Context ctx) { + if (!success) { + ctx.result(message); + ctx.status(status); + return null; + } + + return object; + } +} diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 3c595ca..b255f92 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -9,7 +9,6 @@ import com.mojang.serialization.JsonOps; import io.github.cdimascio.dotenv.Dotenv; import io.javalin.Javalin; -import io.javalin.http.Handler; import io.javalin.json.JsonMapper; import net.modgarden.backend.data.BackendError; import net.modgarden.backend.data.DevelopmentModeData; @@ -20,11 +19,27 @@ import net.modgarden.backend.data.event.Project; import net.modgarden.backend.data.event.Submission; import net.modgarden.backend.data.fixer.DatabaseFixer; -import net.modgarden.backend.data.profile.MinecraftAccount; -import net.modgarden.backend.data.profile.User; -import net.modgarden.backend.handler.v1.discord.*; -import net.modgarden.backend.handler.v1.RegistrationHandler; +import net.modgarden.backend.data.user.User; +import net.modgarden.backend.database.function.GenerateNaturalIdFunction; +import net.modgarden.backend.database.function.HasPermissionsFunction; +import net.modgarden.backend.database.function.UnixMillisFunction; +import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.endpoint.v2.auth.DeleteKeyEndpoint; +import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; +import net.modgarden.backend.endpoint.v2.auth.ListKeysEndpoint; +import net.modgarden.backend.endpoint.v2.project.CreateProjectEndpoint; +import net.modgarden.backend.endpoint.v2.project.DeleteProjectEndpoint; +import net.modgarden.backend.endpoint.v2.submission.GetSubmissionByIdEndpoint; +import net.modgarden.backend.endpoint.v2.event.GetSubmissionByModIdEndpoint; +import net.modgarden.backend.endpoint.v2.project.GetProjectByIdEndpoint; +import net.modgarden.backend.endpoint.v2.project.GetProjectByModIdEndpoint; +import net.modgarden.backend.endpoint.v2.project.member.AddMemberEndpoint; +import net.modgarden.backend.endpoint.v2.project.member.RemoveMemberEndpoint; +import net.modgarden.backend.endpoint.v2.project.member.SetPermissionsEndpoint; +import net.modgarden.backend.endpoint.v2.project.member.SetRoleEndpoint; +import net.modgarden.backend.endpoint.v2.submission.DeleteSubmissionEndpoint; import net.modgarden.backend.util.AuthUtil; +import net.modgarden.backend.util.ReadableOrderCodec; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,320 +50,422 @@ import java.io.InputStreamReader; import java.lang.reflect.Type; import java.net.http.HttpClient; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.sql.Statement; +import java.sql.*; import java.util.HashMap; import java.util.Map; +import java.util.Properties; +import java.util.function.Supplier; public class ModGardenBackend { public static final Dotenv DOTENV = Dotenv.load(); - public static final String URL = "development".equals(DOTENV.get("env")) ? "http://localhost:7070" : "https://api.modgarden.net"; + public static final String URL = "development".equals(DOTENV.get("env")) ? "http://localhost:7070" : "https://api.modgarden.net"; public static final Logger LOG = LoggerFactory.getLogger(ModGardenBackend.class); - public static final int DATABASE_SCHEMA_VERSION = 5; - private static final Map> CODEC_REGISTRY = new HashMap<>(); + private static final Map> CODEC_REGISTRY = new HashMap<>(); public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + private static ModGardenBackend backend; - public static final String SAFE_URL_REGEX = "[a-zA-Z0-9!@$()`.+,_\"-]+"; + private final Javalin app; - public static void main(String[] args) { + private ModGardenBackend(Javalin app) { + this.app = app; + } + + public static void main(String[] args) { if ("development".equals(DOTENV.get("env"))) ((ch.qos.logback.classic.Logger)LOG).setLevel(Level.DEBUG); - try { + Landing.createInstance(); + + try { boolean createdFile = new File("./database.db").createNewFile(); - if (createdFile) { + DatabaseFixer.createFixers(); + if (createdFile) { createDatabaseContents(); updateSchemaVersion(); - LOG.debug("Successfully created database file."); - } - DatabaseFixer.createFixers(); + LOG.debug("Successfully created database file."); + } DatabaseFixer.fixDatabase(); if (!createdFile) { updateSchemaVersion(); } - } catch (IOException ex) { - LOG.error("Failed to create database file.", ex); - } - - CODEC_REGISTRY.put(Landing.class, Landing.CODEC); - CODEC_REGISTRY.put(BackendError.class, BackendError.CODEC); - CODEC_REGISTRY.put(Award.class, Award.DIRECT_CODEC); - CODEC_REGISTRY.put(Event.class, Event.DIRECT_CODEC); - CODEC_REGISTRY.put(MinecraftAccount.class, MinecraftAccount.CODEC); - CODEC_REGISTRY.put(Project.class, Project.DIRECT_CODEC); - CODEC_REGISTRY.put(Submission.class, Submission.DIRECT_CODEC); - CODEC_REGISTRY.put(User.class, User.DIRECT_CODEC); - CODEC_REGISTRY.put(AwardInstance.FullAwardData.class, AwardInstance.FullAwardData.CODEC); - - CODEC_REGISTRY.put(RegistrationHandler.Body.class, RegistrationHandler.Body.CODEC); - CODEC_REGISTRY.put(DiscordBotLinkHandler.Body.class, DiscordBotLinkHandler.Body.CODEC); - CODEC_REGISTRY.put(DiscordBotProfileHandler.PostBody.class, DiscordBotProfileHandler.PostBody.CODEC); - CODEC_REGISTRY.put(DiscordBotProfileHandler.DeleteBody.class, DiscordBotProfileHandler.DeleteBody.CODEC); - CODEC_REGISTRY.put(DiscordBotUnlinkHandler.Body.class, DiscordBotUnlinkHandler.Body.CODEC); - - CODEC_REGISTRY.put(DiscordBotTeamManagementHandler.InviteBody.class, DiscordBotTeamManagementHandler.InviteBody.CODEC); - CODEC_REGISTRY.put(DiscordBotTeamManagementHandler.AcceptInviteBody.class, DiscordBotTeamManagementHandler.AcceptInviteBody.CODEC); - CODEC_REGISTRY.put(DiscordBotTeamManagementHandler.DeclineInviteBody.class, DiscordBotTeamManagementHandler.DeclineInviteBody.CODEC); - CODEC_REGISTRY.put(DiscordBotTeamManagementHandler.RemoveMemberBody.class, DiscordBotTeamManagementHandler.RemoveMemberBody.CODEC); + } catch (IOException ex) { + LOG.error("Failed to create database file.", ex); + } - Landing.createInstance(); - AuthUtil.clearTokensEachFifteenMinutes(); - DiscordBotTeamManagementHandler.clearInvitesEachDay(); + registerCodec(Landing.class, Landing.CODEC); + registerCodec(BackendError.class, BackendError.CODEC); + registerCodec(Award.class, Award.DIRECT_CODEC); + registerCodec(Event.class, Event.DIRECT_CODEC); + registerCodec(Project.class, Project.DIRECT_CODEC); + registerCodec(Submission.class, Submission.DIRECT_CODEC); + registerCodec(User.class, User.DIRECT_CODEC); + registerCodec(AwardInstance.FullAwardData.class, AwardInstance.FullAwardData.CODEC); + registerCodec(GenerateKeyEndpoint.Response.class, GenerateKeyEndpoint.Response.CODEC); + registerCodec(ListKeysEndpoint.Response.class, ListKeysEndpoint.Response.CODEC); + + AuthUtil.clearTokensEachFifteenMinutes(); Javalin app = Javalin.create(config -> config.jsonMapper(createDFUMapper())); app.get("", Landing::getLandingJson); + backend = new ModGardenBackend(app); - v1(app); + backend.v2(); - app.error(400, BackendError::handleError); - app.error(401, BackendError::handleError); - app.error(404, BackendError::handleError); - app.error(422, BackendError::handleError); - app.error(500, BackendError::handleError); + app.error(400, BackendError::handleError); + app.error(401, BackendError::handleError); + app.error(403, BackendError::handleError); + app.error(404, BackendError::handleError); + app.error(422, BackendError::handleError); + app.error(500, BackendError::handleError); app.start(7070); LOG.info("Mod Garden Backend Started!"); - } - - public static void v1(Javalin app) { - get(app, 1, "award/{award}", Award::getAwardType); - - get(app, 1, "event/{event}", Event::getEvent); - get(app, 1, "event/{event}/submissions", Submission::getSubmissionsByEvent); - - get(app, 1, "events", Event::getEvents); - get(app, 1, "events/current/registration", Event::getCurrentRegistrationEvent); - get(app, 1, "events/current/development", Event::getCurrentDevelopmentEvent); - get(app, 1, "events/current/prefreeze", Event::getCurrentPreFreezeEvent); - get(app, 1, "events/active", Event::getActiveEvents); - - get(app, 1, "mcaccount/{mcaccount}", MinecraftAccount::getAccount); - - get(app, 1, "project/{project}", Project::getProject); - - get(app, 1, "submission/{submission}", Submission::getSubmission); - - get(app, 1, "user/{user}", User::getUser); - get(app, 1, "user/{user}/projects", Project::getProjectsByUser); - get(app, 1, "user/{user}/submissions", Submission::getSubmissionsByUser); - get(app, 1, "user/{user}/submissions/{event}", Submission::getSubmissionsByUserAndEvent); - get(app, 1, "user/{user}/awards", Award::getAwardsByUser); - - post(app, 1, "discord/register", RegistrationHandler::discordBotRegister); - - get(app, 1, "discord/oauth/modrinth", DiscordBotOAuthHandler::authModrinthAccount); - get(app, 1, "discord/oauth/minecraft", DiscordBotOAuthHandler::authMinecraftAccount); - get(app, 1, "discord/oauth/minecraft/challenge", DiscordBotOAuthHandler::getMicrosoftCodeChallenge); + } - post(app, 1, "discord/submission/create/modrinth", DiscordBotSubmissionHandler::submitModrinth); - post(app, 1, "discord/submission/modify/version/modrinth", DiscordBotSubmissionHandler::setVersionModrinth); - post(app, 1, "discord/submission/delete", DiscordBotSubmissionHandler::unsubmit); + public void v2() { + post(GenerateKeyEndpoint::new); + delete(DeleteKeyEndpoint::new); + get(ListKeysEndpoint::new); + + post(CreateProjectEndpoint::new); + put(AddMemberEndpoint::new); + put(SetPermissionsEndpoint::new); + put(SetRoleEndpoint::new); + delete(DeleteProjectEndpoint::new); + delete(RemoveMemberEndpoint::new); + get(GetProjectByIdEndpoint::new); + get(GetProjectByModIdEndpoint::new); + + delete(DeleteSubmissionEndpoint::new); + get(GetSubmissionByIdEndpoint::new); + get(GetSubmissionByModIdEndpoint::new); + } - post(app, 1, "discord/link", DiscordBotLinkHandler::link); - post(app, 1, "discord/unlink", DiscordBotUnlinkHandler::unlink); + private void get(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); + this.app.get(endpoint.getPath(), endpoint); + } - post(app, 1, "discord/modify/username", DiscordBotProfileHandler::modifyUsername); - post(app, 1, "discord/modify/displayname", DiscordBotProfileHandler::modifyDisplayName); - post(app, 1, "discord/modify/pronouns", DiscordBotProfileHandler::modifyPronouns); - post(app, 1, "discord/modify/avatar", DiscordBotProfileHandler::modifyAvatarUrl); + private void post(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); + this.app.post(endpoint.getPath(), endpoint); + } - post(app, 1, "discord/remove/pronouns", DiscordBotProfileHandler::removePronouns); - post(app, 1, "discord/remove/avatar", DiscordBotProfileHandler::removeAvatarUrl); + private void put(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); + this.app.put(endpoint.getPath(), endpoint); + } - post(app, 1, "discord/project/user/invite", DiscordBotTeamManagementHandler::sendInvite); - post(app, 1, "discord/project/user/accept", DiscordBotTeamManagementHandler::acceptInvite); - post(app, 1, "discord/project/user/decline", DiscordBotTeamManagementHandler::declineInvite); - post(app, 1, "discord/project/user/remove", DiscordBotTeamManagementHandler::removeMember); + private void delete(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); + this.app.delete(endpoint.getPath(), endpoint); } - @SuppressWarnings("SameParameterValue") - private static void get(Javalin app, int version, String endpoint, Handler consumer) { - app.get("/v" + version + "/" + endpoint, consumer); + public static void registerDatabaseFunctions(Connection connection) throws SQLException { + GenerateNaturalIdFunction.INSTANCE.create(connection); + HasPermissionsFunction.INSTANCE.create(connection); + UnixMillisFunction.INSTANCE.create(connection); } - @SuppressWarnings("SameParameterValue") - private static void post(Javalin app, int version, String endpoint, Handler consumer) { - app.post("/v" + version + "/" + endpoint, consumer); + public static Connection createDatabaseConnection() throws SQLException { + String url = "jdbc:sqlite:database.db"; + Properties props = new Properties(); + props.setProperty("foreign_keys", "true"); + Connection connection = DriverManager.getConnection(url, props); + registerDatabaseFunctions(connection); + return connection; } - public static Connection createDatabaseConnection() throws SQLException { - String url = "jdbc:sqlite:database.db"; - return DriverManager.getConnection(url); - } - - private static void createDatabaseContents() { - try (Connection connection = createDatabaseConnection(); - Statement statement = connection.createStatement()) { - statement.addBatch("CREATE TABLE IF NOT EXISTS users (" + - "id TEXT UNIQUE NOT NULL," + - "username TEXT UNIQUE NOT NULL," + - "display_name TEXT NOT NULL," + - "pronouns TEXT," + - "avatar_url TEXT," + - "discord_id TEXT UNIQUE NOT NULL," + - "modrinth_id TEXT UNIQUE," + - "created INTEGER NOT NULL," + - "permissions INTEGER NOT NULL," + - "PRIMARY KEY(id)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS events (" + - "id TEXT UNIQUE NOT NULL," + - "slug TEXT UNIQUE NOT NULL," + - "display_name TEXT NOT NULL," + - "discord_role_id TEXT, " + - "minecraft_version TEXT NOT NULL," + - "loader TEXT NOT NULL," + - "registration_time INTEGER NOT NULL," + - "start_time INTEGER NOT NULL," + - "end_time INTEGER NOT NULL," + - "freeze_time INTEGER NOT NULL," + - "PRIMARY KEY (id)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS projects (" + - "id TEXT UNIQUE NOT NULL," + - "slug TEXT UNIQUE NOT NULL," + - "modrinth_id TEXT UNIQUE NOT NULL," + - "attributed_to TEXT NOT NULL," + - "FOREIGN KEY (attributed_to) REFERENCES users(id)," + - "PRIMARY KEY (id)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS project_authors (" + - "project_id TEXT NOT NULL," + - "user_id TEXT NOT NULL," + - "FOREIGN KEY (project_id) REFERENCES projects(id)," + - "FOREIGN KEY (user_id) REFERENCES users(id)," + - "PRIMARY KEY (project_id, user_id)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS project_builders (" + - "project_id TEXT NOT NULL," + - "user_id TEXT NOT NULL," + - "FOREIGN KEY (project_id) REFERENCES projects(id)," + - "FOREIGN KEY (user_id) REFERENCES users(id)," + - "PRIMARY KEY (project_id, user_id)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS submissions (" + - "id TEXT UNIQUE NOT NULL," + - "event TEXT NOT NULL," + - "project_id TEXT NOT NULL," + - "modrinth_version_id TEXT NOT NULL," + - "submitted INTEGER NOT NULL," + - "FOREIGN KEY (project_id) REFERENCES projects(id)," + - "FOREIGN KEY (event) REFERENCES events(id)," + - "PRIMARY KEY(id)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS minecraft_accounts (" + - "uuid TEXT UNIQUE NOT NULL," + - "user_id TEXT NOT NULL," + - "FOREIGN KEY (user_id) REFERENCES users(id)," + - "PRIMARY KEY (uuid)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS awards (" + - "id TEXT UNIQUE NOT NULL," + - "slug TEXT UNIQUE NOT NULL," + - "display_name TEXT NOT NULL," + - "sprite TEXT NOT NULL," + - "discord_emote TEXT NOT NULL," + - "tooltip TEXT," + - "tier TEXT NOT NULL CHECK (tier in ('COMMON', 'UNCOMMON', 'RARE', 'LEGENDARY'))," + - "PRIMARY KEY (id)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS award_instances (" + - "award_id TEXT NOT NULL," + - "awarded_to TEXT NOT NULL," + - "custom_data TEXT," + - "submission_id TEXT," + - "tier_override TEXT CHECK (tier_override in ('COMMON', 'UNCOMMON', 'RARE', 'LEGENDARY'))," + - "FOREIGN KEY (award_id) REFERENCES awards(id)," + - "FOREIGN KEY (awarded_to) REFERENCES users(id)," + - "FOREIGN KEY (submission_id) REFERENCES submissions(id)," + - "PRIMARY KEY (award_id, awarded_to)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS link_codes (" + - "code TEXT NOT NULL," + - "account_id TEXT NOT NULL," + - "service TEXT NOT NULL," + - "expires INTEGER NOT NULL," + - "PRIMARY KEY (code)" + - ")"); - statement.addBatch("CREATE TABLE IF NOT EXISTS team_invites (" + - "code TEXT NOT NULL," + - "project_id TEXT NOT NULL," + - "user_id TEXT NOT NULL," + - "expires INTEGER NOT NULL," + - "role TEXT NOT NULL CHECK (role IN ('author', 'builder'))," + - "FOREIGN KEY (project_id) REFERENCES projects(id)," + - "FOREIGN KEY (user_id) REFERENCES users(id)," + - "PRIMARY KEY (code)" + - ")"); - statement.executeBatch(); - } catch (SQLException ex) { - LOG.error("Failed to create database tables. ", ex); - return; - } - LOG.debug("Created database tables."); + private static void createDatabaseContents() { + try (Connection connection = createDatabaseConnection(); + Statement statement = connection.createStatement()) { + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + created INTEGER NOT NULL, + permissions INTEGER NOT NULL, + PRIMARY KEY(id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_bios ( + user_id TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + pronouns TEXT, + description TEXT, + avatar_url TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_bio_fields ( + user_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_value TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE + ) + """); + statement.addBatch(""" + CREATE UNIQUE INDEX idx_user_id_field_name ON user_bio_fields(field_name, field_value) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_keys ( + uuid BLOB NOT NULL, + user_id TEXT NOT NULL, + hash TEXT NOT NULL, + expires INTEGER NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_key_scopes ( + uuid BLOB NOT NULL, + scope TEXT NOT NULL CHECK (scope in ('PROJECT', 'USER')), + project_id TEXT, + permissions INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (uuid) REFERENCES api_keys(uuid) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS passwords ( + user_id TEXT NOT NULL, + hash TEXT NOT NULL, + last_updated INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_integration_modrinth ( + user_id TEXT NOT NULL, + modrinth_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_integration_discord ( + user_id TEXT NOT NULL, + discord_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_integration_minecraft ( + uuid TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS events ( + id TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + event_type_slug TEXT NOT NULL, + display_name TEXT NOT NULL, + minecraft_version TEXT NOT NULL, + loader TEXT NOT NULL, + registration_open_time INTEGER NOT NULL, + registration_close_time INTEGER NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + freeze_time INTEGER NOT NULL, + PRIMARY KEY (id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS event_integration_discord ( + id TEXT UNIQUE NOT NULL, + role_id TEXT NOT NULL, + FOREIGN KEY (id) REFERENCES events(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS projects ( + id TEXT UNIQUE NOT NULL, + PRIMARY KEY (id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_draft_metadata ( + project_id TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (project_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_mod_metadata ( + project_id TEXT UNIQUE NOT NULL, + mod_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + source_url TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (project_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_roles ( + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + role_name TEXT NOT NULL DEFAULT 'Member', + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE + ) + """); + // This ensures that users cannot be listed twice on the same project + statement.addBatch(""" + CREATE UNIQUE INDEX idx_project_roles_two_ids ON project_roles(project_id, user_id) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS submissions ( + id TEXT UNIQUE NOT NULL, + event TEXT NOT NULL, + project_id TEXT NOT NULL, + submitted INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (event) REFERENCES events(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY(id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS submission_type_modrinth ( + submission_id TEXT NOT NULL, + modrinth_id TEXT NOT NULL, + version_id TEXT NOT NULL, + FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (submission_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS awards ( + id TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + sprite TEXT NOT NULL, + discord_emote TEXT NOT NULL, + tooltip TEXT, + tier TEXT NOT NULL CHECK (tier in ('COMMON', 'UNCOMMON', 'RARE', 'LEGENDARY')), + PRIMARY KEY (id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS award_instances ( + award_id TEXT UNIQUE NOT NULL, + awarded_to TEXT NOT NULL, + custom_data TEXT, + submission_id TEXT, + tier_override TEXT CHECK (tier_override in ('COMMON', 'UNCOMMON', 'RARE', 'LEGENDARY')), + FOREIGN KEY (award_id) REFERENCES awards(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (awarded_to) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (award_id, awarded_to) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS link_codes ( + code TEXT NOT NULL, + account_id TEXT NOT NULL, + service TEXT NOT NULL, + expires INTEGER NOT NULL, + PRIMARY KEY (code) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS team_invites ( + code TEXT NOT NULL, + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + expires INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'Member', + permissions INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (code) + ) + """); + + statement.executeBatch(); + } catch (SQLException ex) { + LOG.error("Failed to create database tables. ", ex); + return; + } + LOG.debug("Created database tables."); if ("development".equals(DOTENV.get("env"))) { DevelopmentModeData.insertDevelopmentModeData(); } - } - - private static void updateSchemaVersion() { - try (Connection connection = createDatabaseConnection(); - Statement statement = connection.createStatement()) { - statement.addBatch("CREATE TABLE IF NOT EXISTS schema (version INTEGER NOT NULL, PRIMARY KEY(version))"); - statement.addBatch("DELETE FROM schema"); - statement.executeBatch(); - try (PreparedStatement prepared = connection.prepareStatement("INSERT INTO schema VALUES (?)")) { - prepared.setInt(1, DATABASE_SCHEMA_VERSION); - prepared.execute(); - } - } catch (SQLException ex) { - LOG.error("Failed to update database schema version. ", ex); - return; - } - LOG.debug("Updated database schema version."); - } - - private static JsonMapper createDFUMapper() { - return new JsonMapper() { - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + } + + private static void updateSchemaVersion() { + try (Connection connection = createDatabaseConnection(); + Statement statement = connection.createStatement()) { + statement.addBatch("CREATE TABLE IF NOT EXISTS schema (version INTEGER NOT NULL, PRIMARY KEY(version))"); + statement.addBatch("DELETE FROM schema"); + statement.executeBatch(); + try (PreparedStatement prepared = connection.prepareStatement("INSERT INTO schema VALUES (?)")) { + prepared.setInt(1, DatabaseFixer.getSchemaVersion()); + prepared.execute(); + } + } catch (SQLException ex) { + LOG.error("Failed to update database schema version. ", ex); + return; + } + LOG.debug("Updated database schema version."); + } + + private static void registerCodec(Type type, Codec codec) { + CODEC_REGISTRY.put(type, new ReadableOrderCodec<>(codec)); + } + + private static JsonMapper createDFUMapper() { + return new JsonMapper() { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @SuppressWarnings("unchecked") - @Override - public @NotNull String toJsonString(@NotNull Object obj, @NotNull Type type) { - if (obj instanceof JsonElement) - return GSON.toJson(obj); - if (!CODEC_REGISTRY.containsKey(type)) - throw new UnsupportedOperationException("Cannot encode object type " + type); - return ((Codec)CODEC_REGISTRY.get(type)).encodeStart(JsonOps.INSTANCE, obj).getOrThrow().toString(); - } + @Override + public @NotNull String toJsonString(@NotNull Object obj, @NotNull Type type) { + if (obj instanceof JsonElement) + return GSON.toJson(obj); + if (!CODEC_REGISTRY.containsKey(type)) + throw new UnsupportedOperationException("Cannot encode object type " + type); + return ((Codec)CODEC_REGISTRY.get(type)).encodeStart(JsonOps.INSTANCE, obj).getOrThrow().toString(); + } @SuppressWarnings("unchecked") @Override - public @NotNull T fromJsonString(@NotNull String json, @NotNull Type type) { - if (!CODEC_REGISTRY.containsKey(type)) - throw new UnsupportedOperationException("Cannot decode object type " + type); - return (T) CODEC_REGISTRY.get(type).decode(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow().getFirst(); - } + public @NotNull T fromJsonString(@NotNull String json, @NotNull Type type) { + if (!CODEC_REGISTRY.containsKey(type)) + throw new UnsupportedOperationException("Cannot decode object type " + type); + return (T) CODEC_REGISTRY.get(type).decode(JsonOps.INSTANCE, JsonParser.parseString(json)).getOrThrow().getFirst(); + } @SuppressWarnings("unchecked") - @Override - public @NotNull T fromJsonStream(@NotNull InputStream json, @NotNull Type type) { - if (!CODEC_REGISTRY.containsKey(type)) - throw new UnsupportedOperationException("Cannot decode object type " + type); - try (InputStreamReader reader = new InputStreamReader(json)) { - return (T) CODEC_REGISTRY.get(type).decode(JsonOps.INSTANCE, JsonParser.parseReader(reader)).getOrThrow().getFirst(); - } catch (IOException ex) { - throw new UnsupportedOperationException("Failed to handle JSON input stream.", ex); - } - } - }; - } + @Override + public @NotNull T fromJsonStream(@NotNull InputStream json, @NotNull Type type) { + if (!CODEC_REGISTRY.containsKey(type)) + throw new UnsupportedOperationException("Cannot decode object type " + type); + try (InputStreamReader reader = new InputStreamReader(json)) { + return (T) CODEC_REGISTRY.get(type).decode(JsonOps.INSTANCE, JsonParser.parseReader(reader)).getOrThrow().getFirst(); + } catch (IOException ex) { + throw new UnsupportedOperationException("Failed to handle JSON input stream.", ex); + } + } + }; + } } diff --git a/src/main/java/net/modgarden/backend/data/BackendError.java b/src/main/java/net/modgarden/backend/data/BackendError.java index 5f97e0c..bd1afb0 100644 --- a/src/main/java/net/modgarden/backend/data/BackendError.java +++ b/src/main/java/net/modgarden/backend/data/BackendError.java @@ -5,6 +5,7 @@ import io.javalin.http.Context; import java.util.Locale; +import java.util.Objects; public record BackendError(String error, String description) { public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( @@ -18,6 +19,10 @@ public BackendError(String error, String description) { } public static void handleError(Context ctx) { - ctx.json(new BackendError(ctx.status().getMessage(), ctx.result())); + String result = ctx.result(); + ctx.json(new BackendError( + ctx.status().getMessage(), + Objects.requireNonNullElse(result, "Result is null") + )); } } diff --git a/src/main/java/net/modgarden/backend/data/DevelopmentModeData.java b/src/main/java/net/modgarden/backend/data/DevelopmentModeData.java index 81c3e81..afb52e9 100644 --- a/src/main/java/net/modgarden/backend/data/DevelopmentModeData.java +++ b/src/main/java/net/modgarden/backend/data/DevelopmentModeData.java @@ -13,326 +13,341 @@ public class DevelopmentModeData { public static void insertDevelopmentModeData() { try { - Connection connection = ModGardenBackend.createDatabaseConnection(); - var userStatement = connection.prepareStatement("INSERT OR IGNORE INTO users(id, username, display_name, discord_id, created, permissions, modrinth_id) VALUES (?, ?, ?, ?, ?, ?, ?)"); - long ultrusId = RANDOM.nextLong(Long.MAX_VALUE); - userStatement.setString(1, Long.toString(ultrusId)); - userStatement.setString(2, "ultrusbot"); - userStatement.setString(3, "UltrusBot"); - userStatement.setString(4, "852948197356863528"); - userStatement.setLong(5, System.currentTimeMillis()); - userStatement.setLong(6, 1); - userStatement.setString(7, "RlpLaNSn"); - userStatement.execute(); - - long calicoId = RANDOM.nextLong(Long.MAX_VALUE); - userStatement.setString(1, Long.toString(calicoId)); - userStatement.setString(2, "calico"); - userStatement.setString(3, "Calico"); - userStatement.setString(4, "680986902240690176"); - userStatement.setLong(5, System.currentTimeMillis()); - userStatement.setLong(6, 1); - userStatement.setString(7, "84zsGbft"); - userStatement.execute(); - - long greencowId = RANDOM.nextLong(Long.MAX_VALUE); - userStatement.setString(1, Long.toString(greencowId)); - userStatement.setString(2, "greenbot"); - userStatement.setString(3, "GreenBot"); - userStatement.setString(4, "876135519526977587"); - userStatement.setLong(5, System.currentTimeMillis()); - userStatement.setLong(6, 0); - userStatement.setNull(7, Types.VARCHAR); - userStatement.execute(); - - - var eventStatement = connection.prepareStatement("INSERT OR IGNORE INTO events(id, slug, display_name, discord_role_id, registration_time, start_time, end_time, freeze_time, minecraft_version, loader) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); - long mojankId = RANDOM.nextLong(Long.MAX_VALUE); - eventStatement.setString(1, Long.toString(mojankId)); - eventStatement.setString(2, "mojank-fest"); - eventStatement.setString(3, "MoJank Fest"); - eventStatement.setNull(4, Types.VARCHAR); - eventStatement.setLong(5, System.currentTimeMillis() - (DAY_MILLISECONDS * 365)); - eventStatement.setLong(6, System.currentTimeMillis() - (DAY_MILLISECONDS * 365)); - eventStatement.setLong(7, System.currentTimeMillis() - (DAY_MILLISECONDS * 344)); - eventStatement.setLong(8, System.currentTimeMillis() - (DAY_MILLISECONDS * 304)); - eventStatement.setString(9, "1.20.1"); - eventStatement.setString(10, "fabric"); - eventStatement.execute(); - - long festivalId = RANDOM.nextLong(Long.MAX_VALUE); - eventStatement.setString(1, Long.toString(festivalId)); - eventStatement.setString(2, "festival"); - eventStatement.setString(3, "Mod Garden: Festival"); - eventStatement.setNull(4, Types.VARCHAR); - eventStatement.setLong(5, System.currentTimeMillis() - (DAY_MILLISECONDS * 229)); - eventStatement.setLong(6, System.currentTimeMillis() - (DAY_MILLISECONDS * 222)); - eventStatement.setLong(7, System.currentTimeMillis() - (DAY_MILLISECONDS * 162)); - eventStatement.setLong(8, System.currentTimeMillis() - (DAY_MILLISECONDS * 102)); - eventStatement.setString(9, "1.21.1"); - eventStatement.setString(10, "fabric"); - eventStatement.execute(); - - long exampleGardenId = RANDOM.nextLong(Long.MAX_VALUE); - eventStatement.setString(1, Long.toString(exampleGardenId)); - eventStatement.setString(2, "mod-garden-example"); - eventStatement.setString(3, "Mod Garden: Example"); - eventStatement.setString(4, ModGardenBackend.DOTENV.get("", "")); - eventStatement.setLong(5, System.currentTimeMillis() - (DAY_MILLISECONDS * 7)); - eventStatement.setLong(6, System.currentTimeMillis()); - eventStatement.setLong(7, System.currentTimeMillis() + (DAY_MILLISECONDS * 60)); - eventStatement.setLong(8, System.currentTimeMillis() + (DAY_MILLISECONDS * 120)); - eventStatement.setString(9, "1.21.5"); - eventStatement.setString(10, "fabric"); - eventStatement.execute(); - - long otherEvent = RANDOM.nextLong(Long.MAX_VALUE); - eventStatement.setString(1, Long.toString(otherEvent)); - eventStatement.setString(2, "other-event"); - eventStatement.setString(3, "Other Event"); - eventStatement.setString(4, ModGardenBackend.DOTENV.get("OTHER_EVENT_ROLE_ID", "")); - eventStatement.setLong(5, System.currentTimeMillis() + (DAY_MILLISECONDS * 7)); - eventStatement.setLong(6, System.currentTimeMillis() + (DAY_MILLISECONDS * 14)); - eventStatement.setLong(7, System.currentTimeMillis() + (DAY_MILLISECONDS * 35)); - eventStatement.setLong(8, System.currentTimeMillis() + (DAY_MILLISECONDS * 98)); - eventStatement.setString(9, "1.21.1"); - eventStatement.setString(10, "neoforge"); - - eventStatement.execute(); - - var projectStatement = connection.prepareStatement("INSERT OR IGNORE INTO projects(id, modrinth_id, attributed_to, slug) VALUES (?, ?, ?, ?)"); - long glowBannersId = RANDOM.nextLong(Long.MAX_VALUE); - projectStatement.setString(1, Long.toString(glowBannersId)); - projectStatement.setString(2, "r7G43arb"); - projectStatement.setString(3, Long.toString(ultrusId)); - projectStatement.setString(4, "glow-banners"); - projectStatement.execute(); - - long smeltingTouchId = RANDOM.nextLong(Long.MAX_VALUE); - projectStatement.setString(1, Long.toString(smeltingTouchId)); - projectStatement.setString(2, "otiSEfKe"); - projectStatement.setString(3, Long.toString(ultrusId)); - projectStatement.setString(4, "smelting-touch"); - projectStatement.execute(); - - long bovinesId = RANDOM.nextLong(Long.MAX_VALUE); - projectStatement.setString(1, Long.toString(bovinesId)); - projectStatement.setString(2, "BDg6nMn3"); - projectStatement.setString(3, Long.toString(calicoId)); - projectStatement.setString(4, "bovines-and-buttercups"); - projectStatement.execute(); - - long rapscallionsId = RANDOM.nextLong(Long.MAX_VALUE); - projectStatement.setString(1, Long.toString(rapscallionsId)); - projectStatement.setString(2, "9pGITjpO"); - projectStatement.setString(3, Long.toString(calicoId)); - projectStatement.setString(4, "rapscallions-and-rockhoppers"); - projectStatement.execute(); - - var submissionStatement = connection.prepareStatement("INSERT OR IGNORE INTO submissions(id, event, project_id, modrinth_version_id, submitted) VALUES (?, ?, ?, ?, ?)"); - - long glowBannersSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); - submissionStatement.setString(1, Long.toString(glowBannersSubmissionId)); - submissionStatement.setString(2, Long.toString(mojankId)); - submissionStatement.setString(3, Long.toString(glowBannersId)); - submissionStatement.setString(4, "c2VxpX2M"); - submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 3)); - submissionStatement.execute(); - - long smeltingTouchSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); - submissionStatement.setString(1, Long.toString(smeltingTouchSubmissionId)); - submissionStatement.setString(2, Long.toString(exampleGardenId)); - submissionStatement.setString(3, Long.toString(smeltingTouchId)); - submissionStatement.setString(4, "ubrXE4aR"); - submissionStatement.setLong(5, System.currentTimeMillis() - (86400000)); - submissionStatement.execute(); - - long bovinesMojankSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); - submissionStatement.setString(1, Long.toString(bovinesMojankSubmissionId)); - submissionStatement.setString(2, Long.toString(mojankId)); - submissionStatement.setString(3, Long.toString(bovinesId)); - submissionStatement.setString(4, "j7WIi30J"); - submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 4)); - submissionStatement.execute(); - - long bovinesFestivalSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); - submissionStatement.setString(1, Long.toString(bovinesFestivalSubmissionId)); - submissionStatement.setString(2, Long.toString(festivalId)); - submissionStatement.setString(3, Long.toString(bovinesId)); - submissionStatement.setString(4, "j7WIi30J"); - submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 4)); - submissionStatement.execute(); - - long rapscallionsSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); - submissionStatement.setString(1, Long.toString(rapscallionsSubmissionId)); - submissionStatement.setString(2, Long.toString(exampleGardenId)); - submissionStatement.setString(3, Long.toString(rapscallionsId)); - submissionStatement.setString(4, "HOekJDf0"); - submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 2)); - submissionStatement.execute(); - - var projectAuthorsStatement = connection.prepareStatement("INSERT OR IGNORE INTO project_authors(project_id, user_id) VALUES (?, ?)"); - - // Glow Banners Data - projectAuthorsStatement.setString(1, Long.toString(glowBannersId)); - projectAuthorsStatement.setString(2, Long.toString(ultrusId)); - projectAuthorsStatement.execute(); - - // Smelting Touch Data - projectAuthorsStatement.setString(1, Long.toString(smeltingTouchId)); - projectAuthorsStatement.setString(2, Long.toString(ultrusId)); - projectAuthorsStatement.execute(); - - // Bovines and Buttercups Data - projectAuthorsStatement.setString(1, Long.toString(bovinesId)); - projectAuthorsStatement.setString(2, Long.toString(calicoId)); - projectAuthorsStatement.execute(); - - // Rapscallions and Rockhoppers Data - projectAuthorsStatement.setString(1, Long.toString(rapscallionsId)); - projectAuthorsStatement.setString(2, Long.toString(calicoId)); - projectAuthorsStatement.execute(); - - projectAuthorsStatement.setString(1, Long.toString(rapscallionsId)); - projectAuthorsStatement.setString(2, Long.toString(ultrusId)); - projectAuthorsStatement.execute(); - - var awardsStatement = connection.prepareStatement("INSERT OR IGNORE INTO awards(id, slug, display_name, sprite, discord_emote, tooltip, tier) VALUES (?, ?, ?, ?, ?, ?, ?)"); - long cowAward = RANDOM.nextLong(Long.MAX_VALUE); - awardsStatement.setString(1, Long.toString(cowAward)); - awardsStatement.setString(2, "flower-cow"); - awardsStatement.setString(3, "Flower Cow"); - awardsStatement.setString(4, "cow_award"); - awardsStatement.setString(5, "1065689127774867487"); - awardsStatement.setString(6, "Flower Cow Award: Flower Cow"); - awardsStatement.setString(7, "RARE"); - awardsStatement.execute(); - - long glowAward = RANDOM.nextLong(Long.MAX_VALUE); - awardsStatement.setString(1, Long.toString(glowAward)); - awardsStatement.setString(2, "glowing-award"); - awardsStatement.setString(3, "Glowing Award"); - awardsStatement.setString(4, "glow_award"); - awardsStatement.setString(5, "1205742638884462592"); - awardsStatement.setString(6, "Glowing Award, for mods that have glowing in them"); - awardsStatement.setString(7, "UNCOMMON"); - awardsStatement.execute(); - - long mojankShardsAward = RANDOM.nextLong(Long.MAX_VALUE); - awardsStatement.setString(1, Long.toString(mojankShardsAward)); - awardsStatement.setString(2, "mojank-petals"); - awardsStatement.setString(3, "Mojank Petals"); - awardsStatement.setString(4, "mojank_shards"); - awardsStatement.setString(5, "1333278359874179113"); - awardsStatement.setString(6, "You have collected %custom_data% out of 50 petals in the MoJank Fest event"); - awardsStatement.setString(7, "COMMON"); - awardsStatement.execute(); - - long commonAward = RANDOM.nextLong(Long.MAX_VALUE); - awardsStatement.setString(1, Long.toString(commonAward)); - awardsStatement.setString(2, "common-award"); - awardsStatement.setString(3, "Common Award"); - awardsStatement.setString(4, "common_award"); - awardsStatement.setString(5, "1333278359874179113"); - awardsStatement.setString(6, "Award Tier: Common"); - awardsStatement.setString(7, "COMMON"); - awardsStatement.execute(); - - long uncommonAward = RANDOM.nextLong(Long.MAX_VALUE); - awardsStatement.setString(1, Long.toString(uncommonAward)); - awardsStatement.setString(2, "uncommon-award"); - awardsStatement.setString(3, "Uncommon Award"); - awardsStatement.setString(4, "uncommon_award"); - awardsStatement.setString(5, "1333278359874179113"); - awardsStatement.setString(6, "Award Tier: Uncommon"); - awardsStatement.setString(7, "UNCOMMON"); - awardsStatement.execute(); - - long rareAward = RANDOM.nextLong(Long.MAX_VALUE); - awardsStatement.setString(1, Long.toString(rareAward)); - awardsStatement.setString(2, "rare-award"); - awardsStatement.setString(3, "Rare Award"); - awardsStatement.setString(4, "rare_award"); - awardsStatement.setString(5, "1333278359874179113"); - awardsStatement.setString(6, "Award Tier: Rare"); - awardsStatement.setString(7, "RARE"); - awardsStatement.execute(); - - long legendaryAward = RANDOM.nextLong(Long.MAX_VALUE); - awardsStatement.setString(1, Long.toString(legendaryAward)); - awardsStatement.setString(2, "legendary-award"); - awardsStatement.setString(3, "Legendary Award"); - awardsStatement.setString(4, "legendary_award"); - awardsStatement.setString(5, "1333278359874179113"); - awardsStatement.setString(6, "Award Tier: Legendary"); - awardsStatement.setString(7, "LEGENDARY"); - awardsStatement.execute(); - - var awardInstancesStatement = connection.prepareStatement("INSERT OR IGNORE INTO award_instances(award_id, awarded_to, custom_data, submission_id, tier_override) VALUES (?, ?, ?, ?, ?)"); - awardInstancesStatement.setString(1, Long.toString(cowAward)); - awardInstancesStatement.setString(2, Long.toString(calicoId)); - awardInstancesStatement.setString(3, ""); - awardInstancesStatement.setString(4, Long.toString(bovinesFestivalSubmissionId)); - awardInstancesStatement.setNull(5, Types.VARCHAR); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(glowAward)); - awardInstancesStatement.setString(2, Long.toString(ultrusId)); - awardInstancesStatement.setString(3, ""); - awardInstancesStatement.setString(4, Long.toString(glowBannersSubmissionId)); - awardInstancesStatement.setNull(5, Types.VARCHAR); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(mojankShardsAward)); - awardInstancesStatement.setString(2, Long.toString(ultrusId)); - awardInstancesStatement.setString(3, "25"); - awardInstancesStatement.setNull(4, Types.VARCHAR); - awardInstancesStatement.setString(5, "UNCOMMON"); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(mojankShardsAward)); - awardInstancesStatement.setString(2, Long.toString(calicoId)); - awardInstancesStatement.setString(3, "50"); - awardInstancesStatement.setNull(4, Types.VARCHAR); - awardInstancesStatement.setString(5, "LEGENDARY"); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(mojankShardsAward)); - awardInstancesStatement.setString(2, Long.toString(greencowId)); - awardInstancesStatement.setString(3, "2"); - awardInstancesStatement.setNull(4, Types.VARCHAR); - awardInstancesStatement.setNull(5, Types.VARCHAR); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(commonAward)); - awardInstancesStatement.setString(2, Long.toString(greencowId)); - awardInstancesStatement.setString(3, ""); - awardInstancesStatement.setNull(4, Types.VARCHAR); - awardInstancesStatement.setNull(5, Types.VARCHAR); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(uncommonAward)); - awardInstancesStatement.setString(2, Long.toString(greencowId)); - awardInstancesStatement.setString(3, ""); - awardInstancesStatement.setNull(4, Types.VARCHAR); - awardInstancesStatement.setNull(5, Types.VARCHAR); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(rareAward)); - awardInstancesStatement.setString(2, Long.toString(greencowId)); - awardInstancesStatement.setString(3, ""); - awardInstancesStatement.setNull(4, Types.VARCHAR); - awardInstancesStatement.setNull(5, Types.VARCHAR); - awardInstancesStatement.execute(); - - awardInstancesStatement.setString(1, Long.toString(legendaryAward)); - awardInstancesStatement.setString(2, Long.toString(greencowId)); - awardInstancesStatement.setString(3, ""); - awardInstancesStatement.setNull(4, Types.VARCHAR); - awardInstancesStatement.setNull(5, Types.VARCHAR); - awardInstancesStatement.execute(); - - var minecraftAccountStatement = connection.prepareStatement("INSERT OR IGNORE INTO minecraft_accounts(uuid, user_id) VALUES (?, ?)"); + long ultrusId; + long calicoId; + java.sql.PreparedStatement minecraftAccountStatement; + try (Connection connection = ModGardenBackend.createDatabaseConnection()) { + var userStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO users(id, username, display_name, discord_id, created, permissions, modrinth_id) VALUES (?, ?, ?, ?, ?, ?, ?)"); + ultrusId = RANDOM.nextLong(Long.MAX_VALUE); + userStatement.setString(1, Long.toString(ultrusId)); + userStatement.setString(2, "ultrusbot"); + userStatement.setString(3, "UltrusBot"); + userStatement.setString(4, "852948197356863528"); + userStatement.setLong(5, System.currentTimeMillis()); + userStatement.setLong(6, 1); + userStatement.setString(7, "RlpLaNSn"); + userStatement.execute(); + + calicoId = RANDOM.nextLong(Long.MAX_VALUE); + userStatement.setString(1, Long.toString(calicoId)); + userStatement.setString(2, "calico"); + userStatement.setString(3, "Calico"); + userStatement.setString(4, "680986902240690176"); + userStatement.setLong(5, System.currentTimeMillis()); + userStatement.setLong(6, 1); + userStatement.setString(7, "84zsGbft"); + userStatement.execute(); + + long greencowId = RANDOM.nextLong(Long.MAX_VALUE); + userStatement.setString(1, Long.toString(greencowId)); + userStatement.setString(2, "greenbot"); + userStatement.setString(3, "GreenBot"); + userStatement.setString(4, "876135519526977587"); + userStatement.setLong(5, System.currentTimeMillis()); + userStatement.setLong(6, 0); + userStatement.setNull(7, Types.VARCHAR); + userStatement.execute(); + + + var eventStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO events(id, slug, display_name, discord_role_id, registration_time, start_time, end_time, freeze_time, minecraft_version, loader) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + long mojankId = RANDOM.nextLong(Long.MAX_VALUE); + eventStatement.setString(1, Long.toString(mojankId)); + eventStatement.setString(2, "mojank-fest"); + eventStatement.setString(3, "MoJank Fest"); + eventStatement.setNull(4, Types.VARCHAR); + eventStatement.setLong(5, System.currentTimeMillis() - (DAY_MILLISECONDS * 365)); + eventStatement.setLong(6, System.currentTimeMillis() - (DAY_MILLISECONDS * 365)); + eventStatement.setLong(7, System.currentTimeMillis() - (DAY_MILLISECONDS * 344)); + eventStatement.setLong(8, System.currentTimeMillis() - (DAY_MILLISECONDS * 304)); + eventStatement.setString(9, "1.20.1"); + eventStatement.setString(10, "fabric"); + eventStatement.execute(); + + long festivalId = RANDOM.nextLong(Long.MAX_VALUE); + eventStatement.setString(1, Long.toString(festivalId)); + eventStatement.setString(2, "festival"); + eventStatement.setString(3, "Mod Garden: Festival"); + eventStatement.setNull(4, Types.VARCHAR); + eventStatement.setLong(5, System.currentTimeMillis() - (DAY_MILLISECONDS * 229)); + eventStatement.setLong(6, System.currentTimeMillis() - (DAY_MILLISECONDS * 222)); + eventStatement.setLong(7, System.currentTimeMillis() - (DAY_MILLISECONDS * 162)); + eventStatement.setLong(8, System.currentTimeMillis() - (DAY_MILLISECONDS * 102)); + eventStatement.setString(9, "1.21.1"); + eventStatement.setString(10, "fabric"); + eventStatement.execute(); + + long exampleGardenId = RANDOM.nextLong(Long.MAX_VALUE); + eventStatement.setString(1, Long.toString(exampleGardenId)); + eventStatement.setString(2, "mod-garden-example"); + eventStatement.setString(3, "Mod Garden: Example"); + eventStatement.setString(4, ModGardenBackend.DOTENV.get("", "")); + eventStatement.setLong(5, System.currentTimeMillis() - (DAY_MILLISECONDS * 7)); + eventStatement.setLong(6, System.currentTimeMillis()); + eventStatement.setLong(7, System.currentTimeMillis() + (DAY_MILLISECONDS * 60)); + eventStatement.setLong(8, System.currentTimeMillis() + (DAY_MILLISECONDS * 120)); + eventStatement.setString(9, "1.21.5"); + eventStatement.setString(10, "fabric"); + eventStatement.execute(); + + long otherEvent = RANDOM.nextLong(Long.MAX_VALUE); + eventStatement.setString(1, Long.toString(otherEvent)); + eventStatement.setString(2, "other-event"); + eventStatement.setString(3, "Other Event"); + eventStatement.setString(4, ModGardenBackend.DOTENV.get("OTHER_EVENT_ROLE_ID", "")); + eventStatement.setLong(5, System.currentTimeMillis() + (DAY_MILLISECONDS * 7)); + eventStatement.setLong(6, System.currentTimeMillis() + (DAY_MILLISECONDS * 14)); + eventStatement.setLong(7, System.currentTimeMillis() + (DAY_MILLISECONDS * 35)); + eventStatement.setLong(8, System.currentTimeMillis() + (DAY_MILLISECONDS * 98)); + eventStatement.setString(9, "1.21.1"); + eventStatement.setString(10, "neoforge"); + + eventStatement.execute(); + + var projectStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO projects(id, modrinth_id, attributed_to, slug) VALUES (?, ?, ?, ?)"); + long glowBannersId = RANDOM.nextLong(Long.MAX_VALUE); + projectStatement.setString(1, Long.toString(glowBannersId)); + projectStatement.setString(2, "r7G43arb"); + projectStatement.setString(3, Long.toString(ultrusId)); + projectStatement.setString(4, "glow-banners"); + projectStatement.execute(); + + long smeltingTouchId = RANDOM.nextLong(Long.MAX_VALUE); + projectStatement.setString(1, Long.toString(smeltingTouchId)); + projectStatement.setString(2, "otiSEfKe"); + projectStatement.setString(3, Long.toString(ultrusId)); + projectStatement.setString(4, "smelting-touch"); + projectStatement.execute(); + + long bovinesId = RANDOM.nextLong(Long.MAX_VALUE); + projectStatement.setString(1, Long.toString(bovinesId)); + projectStatement.setString(2, "BDg6nMn3"); + projectStatement.setString(3, Long.toString(calicoId)); + projectStatement.setString(4, "bovines-and-buttercups"); + projectStatement.execute(); + + long rapscallionsId = RANDOM.nextLong(Long.MAX_VALUE); + projectStatement.setString(1, Long.toString(rapscallionsId)); + projectStatement.setString(2, "9pGITjpO"); + projectStatement.setString(3, Long.toString(calicoId)); + projectStatement.setString(4, "rapscallions-and-rockhoppers"); + projectStatement.execute(); + + var submissionStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO submissions(id, event, project_id, modrinth_version_id, submitted) VALUES (?, ?, ?, ?, ?)"); + + long glowBannersSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); + submissionStatement.setString(1, Long.toString(glowBannersSubmissionId)); + submissionStatement.setString(2, Long.toString(mojankId)); + submissionStatement.setString(3, Long.toString(glowBannersId)); + submissionStatement.setString(4, "c2VxpX2M"); + submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 3)); + submissionStatement.execute(); + + long smeltingTouchSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); + submissionStatement.setString(1, Long.toString(smeltingTouchSubmissionId)); + submissionStatement.setString(2, Long.toString(exampleGardenId)); + submissionStatement.setString(3, Long.toString(smeltingTouchId)); + submissionStatement.setString(4, "ubrXE4aR"); + submissionStatement.setLong(5, System.currentTimeMillis() - (86400000)); + submissionStatement.execute(); + + long bovinesMojankSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); + submissionStatement.setString(1, Long.toString(bovinesMojankSubmissionId)); + submissionStatement.setString(2, Long.toString(mojankId)); + submissionStatement.setString(3, Long.toString(bovinesId)); + submissionStatement.setString(4, "j7WIi30J"); + submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 4)); + submissionStatement.execute(); + + long bovinesFestivalSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); + submissionStatement.setString(1, Long.toString(bovinesFestivalSubmissionId)); + submissionStatement.setString(2, Long.toString(festivalId)); + submissionStatement.setString(3, Long.toString(bovinesId)); + submissionStatement.setString(4, "j7WIi30J"); + submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 4)); + submissionStatement.execute(); + + long rapscallionsSubmissionId = RANDOM.nextLong(Long.MAX_VALUE); + submissionStatement.setString(1, Long.toString(rapscallionsSubmissionId)); + submissionStatement.setString(2, Long.toString(exampleGardenId)); + submissionStatement.setString(3, Long.toString(rapscallionsId)); + submissionStatement.setString(4, "HOekJDf0"); + submissionStatement.setLong(5, System.currentTimeMillis() - (86400000 * 2)); + submissionStatement.execute(); + + var projectAuthorsStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO project_authors(project_id, user_id) VALUES (?, ?)"); + + // Glow Banners Data + projectAuthorsStatement.setString(1, Long.toString(glowBannersId)); + projectAuthorsStatement.setString(2, Long.toString(ultrusId)); + projectAuthorsStatement.execute(); + + // Smelting Touch Data + projectAuthorsStatement.setString(1, Long.toString(smeltingTouchId)); + projectAuthorsStatement.setString(2, Long.toString(ultrusId)); + projectAuthorsStatement.execute(); + + // Bovines and Buttercups Data + projectAuthorsStatement.setString(1, Long.toString(bovinesId)); + projectAuthorsStatement.setString(2, Long.toString(calicoId)); + projectAuthorsStatement.execute(); + + // Rapscallions and Rockhoppers Data + projectAuthorsStatement.setString(1, Long.toString(rapscallionsId)); + projectAuthorsStatement.setString(2, Long.toString(calicoId)); + projectAuthorsStatement.execute(); + + projectAuthorsStatement.setString(1, Long.toString(rapscallionsId)); + projectAuthorsStatement.setString(2, Long.toString(ultrusId)); + projectAuthorsStatement.execute(); + + var awardsStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO awards(id, slug, display_name, sprite, discord_emote, tooltip, tier) VALUES (?, ?, ?, ?, ?, ?, ?)"); + long cowAward = RANDOM.nextLong(Long.MAX_VALUE); + awardsStatement.setString(1, Long.toString(cowAward)); + awardsStatement.setString(2, "flower-cow"); + awardsStatement.setString(3, "Flower Cow"); + awardsStatement.setString(4, "cow_award"); + awardsStatement.setString(5, "1065689127774867487"); + awardsStatement.setString(6, "Flower Cow Award: Flower Cow"); + awardsStatement.setString(7, "RARE"); + awardsStatement.execute(); + + long glowAward = RANDOM.nextLong(Long.MAX_VALUE); + awardsStatement.setString(1, Long.toString(glowAward)); + awardsStatement.setString(2, "glowing-award"); + awardsStatement.setString(3, "Glowing Award"); + awardsStatement.setString(4, "glow_award"); + awardsStatement.setString(5, "1205742638884462592"); + awardsStatement.setString(6, "Glowing Award, for mods that have glowing in them"); + awardsStatement.setString(7, "UNCOMMON"); + awardsStatement.execute(); + + long mojankShardsAward = RANDOM.nextLong(Long.MAX_VALUE); + awardsStatement.setString(1, Long.toString(mojankShardsAward)); + awardsStatement.setString(2, "mojank-petals"); + awardsStatement.setString(3, "Mojank Petals"); + awardsStatement.setString(4, "mojank_shards"); + awardsStatement.setString(5, "1333278359874179113"); + awardsStatement.setString( + 6, + "You have collected %custom_data% out of 50 petals in the MoJank Fest event" + ); + awardsStatement.setString(7, "COMMON"); + awardsStatement.execute(); + + long commonAward = RANDOM.nextLong(Long.MAX_VALUE); + awardsStatement.setString(1, Long.toString(commonAward)); + awardsStatement.setString(2, "common-award"); + awardsStatement.setString(3, "Common Award"); + awardsStatement.setString(4, "common_award"); + awardsStatement.setString(5, "1333278359874179113"); + awardsStatement.setString(6, "Award Tier: Common"); + awardsStatement.setString(7, "COMMON"); + awardsStatement.execute(); + + long uncommonAward = RANDOM.nextLong(Long.MAX_VALUE); + awardsStatement.setString(1, Long.toString(uncommonAward)); + awardsStatement.setString(2, "uncommon-award"); + awardsStatement.setString(3, "Uncommon Award"); + awardsStatement.setString(4, "uncommon_award"); + awardsStatement.setString(5, "1333278359874179113"); + awardsStatement.setString(6, "Award Tier: Uncommon"); + awardsStatement.setString(7, "UNCOMMON"); + awardsStatement.execute(); + + long rareAward = RANDOM.nextLong(Long.MAX_VALUE); + awardsStatement.setString(1, Long.toString(rareAward)); + awardsStatement.setString(2, "rare-award"); + awardsStatement.setString(3, "Rare Award"); + awardsStatement.setString(4, "rare_award"); + awardsStatement.setString(5, "1333278359874179113"); + awardsStatement.setString(6, "Award Tier: Rare"); + awardsStatement.setString(7, "RARE"); + awardsStatement.execute(); + + long legendaryAward = RANDOM.nextLong(Long.MAX_VALUE); + awardsStatement.setString(1, Long.toString(legendaryAward)); + awardsStatement.setString(2, "legendary-award"); + awardsStatement.setString(3, "Legendary Award"); + awardsStatement.setString(4, "legendary_award"); + awardsStatement.setString(5, "1333278359874179113"); + awardsStatement.setString(6, "Award Tier: Legendary"); + awardsStatement.setString(7, "LEGENDARY"); + awardsStatement.execute(); + + var awardInstancesStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO award_instances(award_id, awarded_to, custom_data, submission_id, tier_override) VALUES (?, ?, ?, ?, ?)"); + awardInstancesStatement.setString(1, Long.toString(cowAward)); + awardInstancesStatement.setString(2, Long.toString(calicoId)); + awardInstancesStatement.setString(3, ""); + awardInstancesStatement.setString(4, Long.toString(bovinesFestivalSubmissionId)); + awardInstancesStatement.setNull(5, Types.VARCHAR); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(glowAward)); + awardInstancesStatement.setString(2, Long.toString(ultrusId)); + awardInstancesStatement.setString(3, ""); + awardInstancesStatement.setString(4, Long.toString(glowBannersSubmissionId)); + awardInstancesStatement.setNull(5, Types.VARCHAR); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(mojankShardsAward)); + awardInstancesStatement.setString(2, Long.toString(ultrusId)); + awardInstancesStatement.setString(3, "25"); + awardInstancesStatement.setNull(4, Types.VARCHAR); + awardInstancesStatement.setString(5, "UNCOMMON"); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(mojankShardsAward)); + awardInstancesStatement.setString(2, Long.toString(calicoId)); + awardInstancesStatement.setString(3, "50"); + awardInstancesStatement.setNull(4, Types.VARCHAR); + awardInstancesStatement.setString(5, "LEGENDARY"); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(mojankShardsAward)); + awardInstancesStatement.setString(2, Long.toString(greencowId)); + awardInstancesStatement.setString(3, "2"); + awardInstancesStatement.setNull(4, Types.VARCHAR); + awardInstancesStatement.setNull(5, Types.VARCHAR); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(commonAward)); + awardInstancesStatement.setString(2, Long.toString(greencowId)); + awardInstancesStatement.setString(3, ""); + awardInstancesStatement.setNull(4, Types.VARCHAR); + awardInstancesStatement.setNull(5, Types.VARCHAR); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(uncommonAward)); + awardInstancesStatement.setString(2, Long.toString(greencowId)); + awardInstancesStatement.setString(3, ""); + awardInstancesStatement.setNull(4, Types.VARCHAR); + awardInstancesStatement.setNull(5, Types.VARCHAR); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(rareAward)); + awardInstancesStatement.setString(2, Long.toString(greencowId)); + awardInstancesStatement.setString(3, ""); + awardInstancesStatement.setNull(4, Types.VARCHAR); + awardInstancesStatement.setNull(5, Types.VARCHAR); + awardInstancesStatement.execute(); + + awardInstancesStatement.setString(1, Long.toString(legendaryAward)); + awardInstancesStatement.setString(2, Long.toString(greencowId)); + awardInstancesStatement.setString(3, ""); + awardInstancesStatement.setNull(4, Types.VARCHAR); + awardInstancesStatement.setNull(5, Types.VARCHAR); + awardInstancesStatement.execute(); + + minecraftAccountStatement = connection.prepareStatement( + "INSERT OR IGNORE INTO minecraft_accounts(uuid, user_id) VALUES (?, ?)"); + } minecraftAccountStatement.setString(1, "cd21c753fc8d493aa65c25184613402e"); minecraftAccountStatement.setString(2, Long.toString(calicoId)); minecraftAccountStatement.execute(); diff --git a/src/main/java/net/modgarden/backend/data/Integration.java b/src/main/java/net/modgarden/backend/data/Integration.java new file mode 100644 index 0000000..950375d --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/Integration.java @@ -0,0 +1,16 @@ +package net.modgarden.backend.data; + +import com.mojang.serialization.Codec; + +public interface Integration { + Codec getCodec(); + + @SuppressWarnings("unchecked") + static Codec fromCodec(Codec codec) { + //noinspection unchecked + return codec.xmap( + t -> t, + integration -> (T)integration // We can't encode unless an unsafe cast happens. + ); + } +} diff --git a/src/main/java/net/modgarden/backend/data/Metadata.java b/src/main/java/net/modgarden/backend/data/Metadata.java new file mode 100644 index 0000000..a94ed92 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/Metadata.java @@ -0,0 +1,16 @@ +package net.modgarden.backend.data; + +import com.mojang.serialization.MapCodec; + +public interface Metadata { + String getName(); + MapCodec codec(); + + static MapCodec fromMapCodec(MapCodec codec) { + //noinspection unchecked + return codec.xmap( + t -> t, + metadata -> (T)metadata // We can't encode unless an unsafe cast happens. + ); + } +} diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java new file mode 100644 index 0000000..b87cbc7 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -0,0 +1,90 @@ +package net.modgarden.backend.data; + +import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.oauth.OAuthService; +import net.modgarden.backend.oauth.client.BunnyCdnOAuthClient; +import org.jetbrains.annotations.NotNull; + +import java.net.http.HttpResponse; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.random.RandomGenerator; +import java.util.regex.Pattern; + +public final class NaturalId { + private static final Pattern PATTERN = Pattern.compile("^[a-z]{5}$"); + // warning: do not fucking change this until you verify with regex101.com + // also pls create an account and then make a new regex101 and add it to the list below + // https://regex101.com/r/e1Ygne/1 + // see also: regexlicensing.org + private static final Pattern RESERVED_PATTERN = + Pattern.compile("^((z{3}.*)|(.+bot)|(.+acc)|(abcde))$"); + private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyz"; + private static final String MISSINGNO = "noacc"; + + private NaturalId() {} + + public static boolean isReserved(String id) { + return RESERVED_PATTERN.matcher(id).hasMatch(); + } + + public static boolean isValid(String id) { + return PATTERN.matcher(id).hasMatch(); + } + + private static String generateUnchecked(int length) { + StringBuilder builder = new StringBuilder(); + RandomGenerator random = RandomGenerator.getDefault(); + for (int i = 0; i < length; i++) { + builder.append(ALPHABET.charAt(random.nextInt(ALPHABET.length()))); + } + return builder.toString(); + } + + @NotNull + public static String generate(String table, String key, String key2, + int length) throws SQLException { + String id = null; + try (Connection connection1 = ModGardenBackend.createDatabaseConnection()) { + while (id == null) { + String naturalId = generateUnchecked(length); + PreparedStatement exists; + if (key2 != null) { + exists = connection1.prepareStatement("SELECT 1 FROM " + table + " WHERE ? = ? OR ? = ?"); + } else { + exists = connection1.prepareStatement("SELECT 1 FROM " + table + " WHERE ? = ?"); + } + exists.setString(1, key); + exists.setString(2, naturalId); + if (key2 != null) { + exists.setString(3, key2); + exists.setString(4, naturalId); + } + ResultSet resultSet = exists.executeQuery(); + if (!resultSet.getBoolean(1) && !isReserved(naturalId)) { + id = naturalId; + } + } + } + return id; + } + + public static String generateCdnLink(String basePath, int length) throws Exception { + String id = null; + while (id == null) { + String naturalId = generateUnchecked(length); + BunnyCdnOAuthClient client = OAuthService.BUNNY_CDN.authenticate(); + HttpResponse response = client.get(basePath + "/" + naturalId, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() == 404) { + id = naturalId; + } + } + return basePath + "/" + id; + } + + public static String getMissingno() { + return MISSINGNO; + } +} diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index 98b1116..fc9b7d8 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -6,13 +6,37 @@ import java.util.ArrayList; import java.util.List; +import static net.modgarden.backend.data.PermissionScope.*; + // TODO: Add more user permissions for stuff. public enum Permission { - /** - * Signifies that this user has every permission. - * Do not give this out unless it is absolutely necessary for an individual team member to receive this. - */ - ADMINISTRATOR(1, "administrator"); + /// Signifies that this user has every permission. + /// Do not give this out unless it is absolutely necessary for an individual team member to receive this. + ADMINISTRATOR(0x1, "administrator", ALL), + /// Edit your own profile. + EDIT_PROFILE(0x2, "edit_profile", USER), + /// Edit others' profiles and punish users. + MODERATE_USERS(0x4, "moderate_users", USER), + /// Edit this project. + EDIT_PROJECT(0x8, "edit_project", PROJECT), + /// Edit others' projects and hide them. + MODERATE_PROJECTS(0x10, "moderate_projects", ALL), + /// Upload files to the CDN. + UPLOAD_TO_CDN(0x20, "upload_to_cdn", USER), + /// Generate and delete API keys on behalf of this user or project. + MODIFY_API_KEY(0x40, "modify_api_key", ALL), + /// List, modify, and delete files in the CDN. + MANAGE_CDN(0x80, "manage_cdn", USER), + /// Edit events and hide them. + EDIT_EVENT(0x100, "edit_event", USER); + + /// The default permissions that all users have. + /// + /// At some point, we're going to switch to user roles, + /// but for now, users have inherent, default permissions. + public static final Permissions DEFAULT_USER_PERMISSIONS = new Permissions( + EDIT_PROFILE + ); public static final Codec CODEC = Codec.STRING.flatXmap(string -> { try { @@ -21,19 +45,26 @@ public enum Permission { return DataResult.error(() -> "Could not find permission '" + string + "'"); } }, permission -> DataResult.success(permission.name)); - public static final Codec> LIST_CODEC = Codec.withAlternative(CODEC.listOf(), Codec.LONG.xmap(Permission::fromLong, Permission::toLong)); + public static final Codec> GLOBAL_LIST_CODEC = Codec.withAlternative(CODEC.listOf(), Codec.STRING.xmap(string -> fromLongString(string, + USER + ), Permission::toLongString)); + public static final Codec> PROJECT_LIST_CODEC = Codec.withAlternative(CODEC.listOf(), Codec.STRING.xmap(string -> fromLongString(string, PROJECT), Permission::toLongString)); + public static final Codec PERMISSIONS_CODEC = Codec.LONG.xmap(Permissions::new, Permissions::bits); + public static final Codec STRING_PERMISSIONS_CODEC = Codec.STRING.xmap(Permissions::new, Permissions::toString); private final long bit; private final String name; + private final PermissionScope kind; - Permission(int bit, String name) { + Permission(int bit, String name, PermissionScope kind) { this.bit = bit; this.name = name; + this.kind = kind; } - public static List fromLong(long value) { + public static List fromLong(long value, PermissionScope kind) { List permissions = new ArrayList<>(); - for (Permission permission : Permission.values()) { + for (Permission permission : Permission.values(kind)) { if (hasPermissionRaw(value, permission)) { permissions.add(permission); } @@ -41,6 +72,10 @@ public static List fromLong(long value) { return permissions; } + public static List fromLongString(String value, PermissionScope kind) { + return fromLong(Long.parseLong(value), kind); + } + public static long toLong(List permissions) { long value = 0; for (Permission permission : permissions) { @@ -49,6 +84,10 @@ public static long toLong(List permissions) { return value; } + public static String toLongString(List permissions) { + return Long.toString(toLong(permissions)); + } + public static long grantPermission(long previousValue, Permission permission) { long newValue = previousValue; newValue |= permission.bit; @@ -69,6 +108,20 @@ private static boolean hasPermissionRaw(long userPermissions, Permission permiss return (userPermissions & permission.bit) != 0; } + private static List values(PermissionScope kind) { + List permissions = new ArrayList<>(); + for (Permission permission : Permission.values()) { + if (permission.kind == ALL || permission.kind == kind) { + permissions.add(permission); + } + } + return permissions; + } + + public long getBit() { + return this.bit; + } + public String getName() { return name; } diff --git a/src/main/java/net/modgarden/backend/data/PermissionScope.java b/src/main/java/net/modgarden/backend/data/PermissionScope.java new file mode 100644 index 0000000..09f1cf6 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/PermissionScope.java @@ -0,0 +1,20 @@ +package net.modgarden.backend.data; + +import com.mojang.serialization.Codec; + +import java.util.Locale; + +public enum PermissionScope { + ALL, + USER, + PROJECT,; + + public static final Codec CODEC = Codec.STRING.xmap( + PermissionScope::fromString, + scope -> scope.name().toLowerCase(Locale.ROOT) + ); + + public static PermissionScope fromString(String string) { + return valueOf(string.toUpperCase(Locale.ROOT)); + } +} diff --git a/src/main/java/net/modgarden/backend/data/Permissions.java b/src/main/java/net/modgarden/backend/data/Permissions.java new file mode 100644 index 0000000..e52ebdf --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/Permissions.java @@ -0,0 +1,68 @@ +package net.modgarden.backend.data; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/// A bitfield of permissions that uses the [Permission] system. +/// +/// Note that once value classes come out, this class will become a value class. +public record Permissions(long bits) { + public Permissions(Permission... permissions) { + this(Permission.toLong(List.of(permissions))); + } + + public Permissions(String bitsString) { + this(Long.parseLong(bitsString)); + } + + public Permissions grantPermissions(Permissions permissions) { + return new Permissions(this.bits | permissions.bits); + } + + public Permissions grantPermissions(Permission... permissions) { + return this.grantPermissions(new Permissions(permissions)); + } + + public Permissions revokePermissions(Permissions permissions) { + return new Permissions(this.bits ^ permissions.bits); + } + + public Permissions revokePermissions(Permission... permissions) { + return this.revokePermissions(new Permissions(permissions)); + } + + public boolean hasPermissions(Permissions required) { + boolean hasPermissions = (required.bits & this.bits) == required.bits; + boolean hasAdministrator = hasAdministrator(this.bits); + return hasAdministrator || hasPermissions; + } + + public boolean hasAnyPermissions(Permissions required) { + boolean hasPermissions = (required.bits & this.bits) > 0; + boolean hasAdministrator = hasAdministrator(this.bits); + return hasAdministrator || hasPermissions; + } + + public boolean hasPermissions(Permission... permissions) { + return this.hasPermissions(new Permissions(permissions)); + } + + public boolean hasAnyPermissions(Permission... permissions) { + return this.hasAnyPermissions(new Permissions(permissions)); + } + + /// Only allows permissions in [#bits] and ignores all other permissions. + public Permissions restrict(long bits) { + return new Permissions(this.bits & bits); + } + + private static boolean hasAdministrator(long bits) { + return (bits & Permission.ADMINISTRATOR.getBit()) != 0; + } + + @NotNull + public String toString() { + return Long.toString(this.bits); + } +} diff --git a/src/main/java/net/modgarden/backend/data/Platform.java b/src/main/java/net/modgarden/backend/data/Platform.java new file mode 100644 index 0000000..5d2db3a --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/Platform.java @@ -0,0 +1,16 @@ +package net.modgarden.backend.data; + +import com.mojang.serialization.MapCodec; + +public interface Platform { + String getName(); + MapCodec getCodec(); + + static MapCodec fromMapCodec(MapCodec codec) { + //noinspection unchecked + return codec.xmap( + t -> t, + metadata -> (T)metadata // We can't encode unless an unsafe cast happens. + ); + } +} diff --git a/src/main/java/net/modgarden/backend/data/award/Award.java b/src/main/java/net/modgarden/backend/data/award/Award.java index fc4030b..08fcea4 100644 --- a/src/main/java/net/modgarden/backend/data/award/Award.java +++ b/src/main/java/net/modgarden/backend/data/award/Award.java @@ -5,9 +5,9 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; import com.mojang.serialization.codecs.RecordCodecBuilder; -import de.mkammerer.snowflakeid.SnowflakeIdGenerator; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.endpoint.Endpoint; import java.sql.Connection; import java.sql.PreparedStatement; @@ -20,7 +20,6 @@ public record Award(String id, String sprite, String discordEmote, String tooltip) { - public static final SnowflakeIdGenerator ID_GENERATOR = SnowflakeIdGenerator.createDefault(4); public static final Codec DIRECT_CODEC = RecordCodecBuilder.create(inst -> inst.group( Codec.STRING.fieldOf("id").forGetter(Award::id), Codec.STRING.fieldOf("slug").forGetter(Award::slug), @@ -34,7 +33,7 @@ public record Award(String id, public static void getAwardType(Context ctx) { String path = ctx.pathParam("award"); - if (!path.matches(ModGardenBackend.SAFE_URL_REGEX)) { + if (!path.matches(Endpoint.SAFE_URL_REGEX)) { ctx.result("Illegal characters in path '" + path + "'."); ctx.status(422); return; @@ -77,14 +76,13 @@ private static Award innerQuery(String whereStatement, String id) { public static void getAwardsByUser(Context ctx) { String user = ctx.pathParam("user"); - if (!user.matches(ModGardenBackend.SAFE_URL_REGEX)) { + if (!user.matches(Endpoint.SAFE_URL_REGEX)) { ctx.result("Illegal characters in path '" + user + "'."); ctx.status(422); return; } var queryString = selectAllByUser(user); - try { - Connection connection = ModGardenBackend.createDatabaseConnection(); + try (Connection connection = ModGardenBackend.createDatabaseConnection()) { PreparedStatement prepared = connection.prepareStatement(queryString); ResultSet result = prepared.executeQuery(); var awards = new JsonArray(); diff --git a/src/main/java/net/modgarden/backend/data/award/AwardInstance.java b/src/main/java/net/modgarden/backend/data/award/AwardInstance.java index 61e3fdc..9ac3807 100644 --- a/src/main/java/net/modgarden/backend/data/award/AwardInstance.java +++ b/src/main/java/net/modgarden/backend/data/award/AwardInstance.java @@ -3,18 +3,18 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; import net.modgarden.backend.data.event.Submission; -import net.modgarden.backend.data.profile.User; +import net.modgarden.backend.data.user.User; public record AwardInstance(String awardId, String awardedTo, String customData, - Submission submission, + String submission, AwardTier tier) { public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( Award.ID_CODEC.fieldOf("award_id").forGetter(AwardInstance::awardId), User.ID_CODEC.fieldOf("awarded_to").forGetter(AwardInstance::awardedTo), Codec.STRING.fieldOf("custom_data").forGetter(AwardInstance::customData), - Submission.CODEC.fieldOf("submission").forGetter(AwardInstance::submission), + Submission.ID_CODEC.fieldOf("submission").forGetter(AwardInstance::submission), AwardTier.CODEC.fieldOf("tier_override").forGetter(AwardInstance::tier) ).apply(inst, AwardInstance::new)); diff --git a/src/main/java/net/modgarden/backend/data/event/Event.java b/src/main/java/net/modgarden/backend/data/event/Event.java index 445a651..9680501 100644 --- a/src/main/java/net/modgarden/backend/data/event/Event.java +++ b/src/main/java/net/modgarden/backend/data/event/Event.java @@ -1,332 +1,45 @@ package net.modgarden.backend.data.event; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; -import com.mojang.serialization.JsonOps; import com.mojang.serialization.codecs.RecordCodecBuilder; -import de.mkammerer.snowflakeid.SnowflakeIdGenerator; -import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.util.ExtraCodecs; -import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.Locale; import java.util.Optional; public record Event(String id, String slug, + String eventTypeSlug, String displayName, Optional discordRoleId, String minecraftVersion, String loader, - ZonedDateTime registrationTime, - ZonedDateTime startTime, - ZonedDateTime endTime, - ZonedDateTime freezeTime) { - // TODO: Endpoint for creating events. - public static final SnowflakeIdGenerator ID_GENERATOR = SnowflakeIdGenerator.createDefault(1); + long registrationOpenTime, + long registrationCloseTime, + long startTime, + long endTime, + long freezeTime) { public static final Codec DIRECT_CODEC = Codec.lazyInitialized(() -> RecordCodecBuilder.create(inst -> inst.group( Codec.STRING.fieldOf("id").forGetter(Event::id), Codec.STRING.fieldOf("slug").forGetter(Event::slug), - Codec.STRING.fieldOf("display_name").forGetter(Event::displayName), + Codec.STRING.fieldOf("event_type_slug").forGetter(Event::eventTypeSlug), + Codec.STRING.fieldOf("display_name").forGetter(Event::displayName), Codec.STRING.optionalFieldOf("discord_role_id").forGetter(Event::discordRoleId), Codec.STRING.fieldOf("minecraft_version").forGetter(Event::minecraftVersion), Codec.STRING.fieldOf("loader").forGetter(Event::loader), - ExtraCodecs.ISO_DATE_TIME.fieldOf("registration_time").forGetter(Event::registrationTime), - ExtraCodecs.ISO_DATE_TIME.fieldOf("start_time").forGetter(Event::startTime), - ExtraCodecs.ISO_DATE_TIME.fieldOf("end_time").forGetter(Event::endTime), - ExtraCodecs.ISO_DATE_TIME.fieldOf("freeze_time").forGetter(Event::freezeTime) + Codec.LONG.fieldOf("registration_open_time").forGetter(Event::registrationOpenTime), + Codec.LONG.fieldOf("registration_close_time").forGetter(Event::registrationCloseTime), + Codec.LONG.fieldOf("start_time").forGetter(Event::startTime), + Codec.LONG.fieldOf("end_time").forGetter(Event::endTime), + Codec.LONG.fieldOf("freeze_time").forGetter(Event::freezeTime) ).apply(inst, Event::new))); - public static final Codec ID_CODEC = Codec.STRING.validate(Event::validateFromId); - public static final Codec SLUG_CODEC = Codec.STRING.validate(Event::validateFromSlug); - public static final Codec FROM_ID_CODEC = ID_CODEC.xmap(Event::queryFromId, Event::id); - public static final Codec FROM_SLUG_CODEC = SLUG_CODEC.xmap(Event::queryFromSlug, Event::slug); + public static final Codec ID_CODEC = Codec.STRING.validate(Event::validate); - public static void getEvent(Context ctx) { - String path = ctx.pathParam("event"); - if (!path.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + path + "'."); - ctx.status(422); - return; - } - Event event = query(path); - if (event == null) { - ModGardenBackend.LOG.debug("Could not find event '{}'.", path); - ctx.result("Could not find event '" + path + "'."); - ctx.status(404); - return; - } - - ModGardenBackend.LOG.debug("Successfully queried event from path '{}'", path); - ctx.json(event); - } - - public static void getCurrentRegistrationEvent(Context ctx) { - Event event = null; - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement preparedStatement = connection.prepareStatement(selectStatement("e.registration_time <= ? AND e.end_time > ?", "registration_time"))) { - long currentMillis = System.currentTimeMillis(); - preparedStatement.setLong(1, currentMillis); - preparedStatement.setLong(2, currentMillis); - ResultSet result = preparedStatement.executeQuery(); - if (result.isBeforeFirst()) { - event = new Event( - result.getString("id"), - result.getString("slug"), - result.getString("display_name"), - Optional.ofNullable(result.getString("discord_role_id")), - result.getString("minecraft_version"), - result.getString("loader"), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("registration_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("start_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("end_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("freeze_time")), ZoneId.of("GMT")) - ); - } - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - - if (event == null) { - ModGardenBackend.LOG.debug("Could not find a current event with registration time active."); - ctx.result("No current event with registration time active."); - ctx.status(404); - return; - } - - ModGardenBackend.LOG.debug("Successfully queried a current event ({}) with registration time active.", event.slug); - ctx.json(event); - } - - public static void getCurrentDevelopmentEvent(Context ctx) { - Event event = null; - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement preparedStatement = connection.prepareStatement(selectStatement("start_time <= ? AND end_time > ?", "start_time"))) { - long currentMillis = System.currentTimeMillis(); - preparedStatement.setLong(1, currentMillis); - preparedStatement.setLong(2, currentMillis); - ResultSet result = preparedStatement.executeQuery(); - if (result.isBeforeFirst()) { - event = new Event( - result.getString("id"), - result.getString("slug"), - result.getString("display_name"), - Optional.ofNullable(result.getString("discord_role_id")), - result.getString("minecraft_version"), - result.getString("loader"), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("registration_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("start_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("end_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("freeze_time")), ZoneId.of("GMT")) - ); - } - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - - if (event == null) { - ModGardenBackend.LOG.debug("Could not find a current event with development time active."); - ctx.result("No current event with development time active."); - ctx.status(404); - return; - } - - ModGardenBackend.LOG.debug("Successfully queried a current event ({}) with development time active.", event.slug); - ctx.json(event); - } - - - public static void getCurrentPreFreezeEvent(Context ctx) { - Event event = null; - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement preparedStatement = connection.prepareStatement(selectStatement("start_time <= ? AND freeze_time > ?", "start_time"))) { - long currentMillis = System.currentTimeMillis(); - preparedStatement.setLong(1, currentMillis); - preparedStatement.setLong(2, currentMillis); - ResultSet result = preparedStatement.executeQuery(); - if (result.isBeforeFirst()) { - event = new Event( - result.getString("id"), - result.getString("slug"), - result.getString("display_name"), - Optional.ofNullable(result.getString("discord_role_id")), - result.getString("minecraft_version"), - result.getString("loader"), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("registration_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("start_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("end_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("freeze_time")), ZoneId.of("GMT")) - ); - } - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - - if (event == null) { - ModGardenBackend.LOG.debug("Could not find a current event pre-freeze."); - ctx.result("No current event pre-freeze."); - ctx.status(404); - return; - } - - ModGardenBackend.LOG.debug("Successfully queried a current event ({}) pre-freeze.", event.slug); - ctx.json(event); - } - - public static void getEvents(Context ctx) { - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - var result = connection.createStatement().executeQuery("SELECT * FROM events"); - var events = new JsonArray(); - while (result.next()) { - var event = new JsonObject(); - event.addProperty("id", result.getString("id")); - event.addProperty("slug", result.getString("slug")); - event.addProperty("display_name", result.getString("display_name")); - - if (result.getString("discord_role_id") != null) { - event.addProperty("discord_role_id", result.getString("discord_role_id")); - } - - event.addProperty("minecraft_version", result.getString("minecraft_version")); - event.addProperty("loader", result.getLong("loader")); - event.add("registration_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("registration_time")), ZoneId.of("GMT"))) - .getOrThrow()); - event.add("start_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("start_time")), ZoneId.of("GMT"))) - .getOrThrow()); - event.add("end_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("end_time")), ZoneId.of("GMT"))) - .getOrThrow()); - event.add("freeze_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("freeze_time")), ZoneId.of("GMT"))) - .getOrThrow()); - events.add(event); - } - ctx.json(events); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - } - - public static void getActiveEvents(Context ctx) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement preparedStatement = connection.prepareStatement("SELECT * FROM events WHERE start_time <= ? AND end_time > ?")) { - long currentMillis = System.currentTimeMillis(); - preparedStatement.setLong(1, currentMillis); - preparedStatement.setLong(2, currentMillis); - var result = preparedStatement.executeQuery(); - var events = new JsonArray(); - while (result.next()) { - var event = new JsonObject(); - event.addProperty("id", result.getString("id")); - event.addProperty("slug", result.getString("slug")); - event.addProperty("display_name", result.getString("display_name")); - - if (result.getString("discord_role_id") != null) { - event.addProperty("discord_role_id", result.getString("discord_role_id")); - } - - event.addProperty("minecraft_version", result.getString("minecraft_version")); - event.addProperty("loader", result.getLong("loader")); - event.add("registration_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("registration_time")), ZoneId.of("GMT"))) - .getOrThrow()); - event.add("start_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("start_time")), ZoneId.of("GMT"))) - .getOrThrow()); - event.add("end_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("end_time")), ZoneId.of("GMT"))) - .getOrThrow()); - event.add("freeze_time", - ExtraCodecs.ISO_DATE_TIME - .encodeStart(JsonOps.INSTANCE, ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("freeze_time")), ZoneId.of("GMT"))) - .getOrThrow()); - events.add(event); - } - ctx.json(events); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - } - - @Nullable - public static Event query(String path) { - Event event = queryFromSlug(path.toLowerCase(Locale.ROOT)); - - if (event == null) - event = queryFromId(path); - - return event; - } - - public static Event queryFromId(String id) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectStatement("e.id = ?", "id"))) { - prepared.setString(1, id); - ResultSet result = prepared.executeQuery(); - if (!result.isBeforeFirst()) - return null; - return new Event( - result.getString("id"), - result.getString("slug"), - result.getString("display_name"), - Optional.ofNullable(result.getString("discord_role_id")), - result.getString("minecraft_version"), - result.getString("loader"), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("registration_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("start_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("end_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("freeze_time")), ZoneId.of("GMT")) - ); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return null; - } - - public static Event queryFromSlug(String slug) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectStatement("e.slug = ?", "slug"))) { - prepared.setString(1, slug); - ResultSet result = prepared.executeQuery(); - if (!result.isBeforeFirst()) - return null; - return new Event( - result.getString("id"), - result.getString("slug"), - result.getString("display_name"), - Optional.ofNullable(result.getString("discord_role_id")), - result.getString("minecraft_version"), - result.getString("loader"), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("registration_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("start_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("end_time")), ZoneId.of("GMT")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("freeze_time")), ZoneId.of("GMT")) - ); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return null; - } - - private static DataResult validateFromId(String id) { + private static DataResult validate(String id) { try (Connection connection = ModGardenBackend.createDatabaseConnection(); PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM events WHERE id = ?")) { prepared.setString(1, id); @@ -338,38 +51,4 @@ private static DataResult validateFromId(String id) { } return DataResult.error(() -> "Failed to get event with id '" + id + "'."); } - - private static DataResult validateFromSlug(String slug) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM events WHERE slug = ?")) { - prepared.setString(1, slug); - ResultSet result = prepared.executeQuery(); - if (result != null && result.getBoolean(1)) - return DataResult.success(slug); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return DataResult.error(() -> "Failed to get event with slug '" + slug + "'."); - } - - private static String selectStatement(String whereStatement, String orderBy) { - return """ - SELECT - e.id, - e.slug, - e.display_name, - e.discord_role_id, - e.minecraft_version, - e.loader, - e.registration_time, - e.start_time, - e.end_time, - e.freeze_time - FROM events e - WHERE""" - + " " + whereStatement + " " + - "ORDER BY " - + orderBy + - " LIMIT 1;"; - } } diff --git a/src/main/java/net/modgarden/backend/data/event/Project.java b/src/main/java/net/modgarden/backend/data/event/Project.java index b7b00db..dab3073 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -1,270 +1,56 @@ package net.modgarden.backend.data.event; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; +import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import de.mkammerer.snowflakeid.SnowflakeIdGenerator; -import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.profile.User; +import net.modgarden.backend.data.Metadata; +import net.modgarden.backend.data.event.metadata.DraftMetadata; +import net.modgarden.backend.data.event.metadata.ModMetadata; +import net.modgarden.backend.data.user.User; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.Arrays; import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static net.modgarden.backend.data.Metadata.fromMapCodec; // TODO: Allow creating organisations, allow projects to be attributed to an organisation. -// TODO: Potentially allow GitHub only projects. Not necessarily now, but more notes on this will be placed in internal team chats. - Calico public record Project(String id, - String slug, - String modrinthId, - String attributedTo, - List authors, - List builders) { - public static final SnowflakeIdGenerator ID_GENERATOR = SnowflakeIdGenerator.createDefault(2); - public static final Codec DIRECT_CODEC = Codec.lazyInitialized(() -> RecordCodecBuilder.create(inst -> inst.group( + Metadata metadata, + Map team, + Map permissions, + List submissions) { + private static final Map> METADATA_MAP_CODECS = Map.ofEntries( + entry("draft", fromMapCodec(DraftMetadata.CODEC)), + entry("mod", fromMapCodec(ModMetadata.CODEC)) + ); + private static final Codec METADATA_CODEC = Codec.STRING.dispatch(Metadata::getName, METADATA_MAP_CODECS::get); + + public static final Codec DIRECT_CODEC = Codec.lazyInitialized(() -> RecordCodecBuilder.create(inst -> inst.group( Codec.STRING.fieldOf("id").forGetter(Project::id), - Codec.STRING.fieldOf("slug").forGetter(Project::slug), - Codec.STRING.fieldOf("modrinth_id").forGetter(Project::modrinthId), - User.ID_CODEC.fieldOf("attributed_to").forGetter(Project::attributedTo), - User.ID_CODEC.listOf().fieldOf("authors").forGetter(Project::authors), - User.ID_CODEC.listOf().fieldOf("builders").forGetter(Project::builders) + METADATA_CODEC.fieldOf("metadata").forGetter(Project::metadata), + Codec.unboundedMap(User.ID_CODEC, Codec.STRING).fieldOf("team").forGetter(Project::team), + Codec.unboundedMap(User.ID_CODEC, Codec.LONG).fieldOf("permissions").forGetter(Project::permissions), + Codec.list(Submission.ID_CODEC).fieldOf("submissions").forGetter(Project::submissions) ).apply(inst, Project::new))); public static final Codec ID_CODEC = Codec.STRING.validate(Project::validate); - public static final Codec CODEC = ID_CODEC.xmap(Project::queryFromId, Project::id); - - public static void getProject(Context ctx) { - String path = ctx.pathParam("project"); - if (!path.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + path + "'."); - ctx.status(422); - return; - } - // TODO: Allow Modrinth as a service. - Project project = queryFromSlug(path); - if (project == null) { - project = queryFromId(path); - } - if (project == null) { - ModGardenBackend.LOG.debug("Could not find project '{}'.", path); - ctx.result("Could not find project '" + path + "'."); - ctx.status(404); - return; - } - ModGardenBackend.LOG.debug("Successfully queried project from path '{}'", path); - ctx.json(project); - } - - public static Project queryFromSlug(String slug) { + private static DataResult validate(String id) { try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectBySlug())) { - prepared.setString(1, slug); + PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM projects WHERE id = ?")) { + prepared.setString(1, id); ResultSet result = prepared.executeQuery(); - if (!result.isBeforeFirst()) - return null; - List authors = Arrays.stream(result.getString("authors").split(",")) - .filter(s -> !s.isBlank()) - .toList(); - List builders = Arrays.stream(result.getString("builders").split(",")) - .filter(s -> !s.isBlank()) - .toList(); - return new Project( - result.getString("id"), - result.getString("slug"), - result.getString("modrinth_id"), - result.getString("attributed_to"), - authors, - builders - ); + if (result != null && result.getBoolean(1)) + return DataResult.success(id); } catch (SQLException ex) { ModGardenBackend.LOG.error("Exception in SQL query.", ex); } - return null; + return DataResult.error(() -> "Failed to get project with id '" + id + "'."); } - - public static Project queryFromId(String id) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectById())) { - prepared.setString(1, id); - ResultSet result = prepared.executeQuery(); - if (!result.isBeforeFirst()) - return null; - List authors = Arrays.stream(result.getString("authors").split(",")) - .filter(s -> !s.isBlank()) - .toList(); - List builders = Arrays.stream(result.getString("builders").split(",")) - .filter(s -> !s.isBlank()) - .toList(); - return new Project( - result.getString("id"), - result.getString("slug"), - result.getString("modrinth_id"), - result.getString("attributed_to"), - authors, - builders - ); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return null; - } - - public static void getProjectsByUser(Context ctx) { - String user = ctx.pathParam("user"); - if (!user.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + user + "'."); - ctx.status(422); - return; - } - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectAllByUser())) { - prepared.setString(1, user); - prepared.setString(2, user); - ResultSet result = prepared.executeQuery(); - var projectList = new JsonArray(); - while (result.next()) { - var projectObject = new JsonObject(); - var authors = new JsonArray(); - var builders = new JsonArray(); - - for (String author : result.getString("authors").split(",")) { - authors.add(author); - } - for (String builder : result.getString("builders").split(",")) { - builders.add(builder); - } - projectObject.addProperty("id", result.getString("id")); - projectObject.addProperty("slug", result.getString("slug")); - projectObject.addProperty("modrinth_id", result.getString("modrinth_id")); - projectObject.addProperty("attributed_to", result.getString("attributed_to")); - projectObject.add("authors", authors); - projectObject.add("builders", builders); - projectList.add(projectObject); - } - ctx.json(projectList); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - } - - private static String selectById() { - return """ - SELECT - p.id, - p.slug, - p.modrinth_id, - p.attributed_to, - COALESCE(Group_concat(DISTINCT a.user_id), '') AS authors, - COALESCE(Group_concat(DISTINCT b.user_id), '') AS builders - FROM projects p - LEFT JOIN project_authors a - ON p.id = a.project_id - LEFT JOIN project_builders b - ON p.id = b.project_id - WHERE - p.id = ? - GROUP BY - p.id, - p.slug, - p.modrinth_id, - p.attributed_to - """; - } - - private static String selectBySlug() { - return """ - SELECT - p.id, - p.slug, - p.modrinth_id, - p.attributed_to, - COALESCE(Group_concat(DISTINCT a.user_id), '') AS authors, - COALESCE(Group_concat(DISTINCT b.user_id), '') AS builders - FROM projects p - LEFT JOIN project_authors a - ON p.id = a.project_id - LEFT JOIN project_builders b - ON p.id = b.project_id - WHERE - p.slug = ? - GROUP BY - p.id, - p.slug, - p.modrinth_id, - p.attributed_to - """; - } - - private static String selectAllByUser() { - return """ - SELECT p.id, - p.slug, - p.modrinth_id, - p.attributed_to, - COALESCE(Group_concat(DISTINCT a.user_id), '') AS authors, - COALESCE(Group_concat(DISTINCT b.user_id), '') AS builders - FROM projects p - LEFT JOIN project_authors a - ON p.id = a.project_id - LEFT JOIN project_builders b - ON p.id = b.project_id - LEFT JOIN users u - ON a.user_id = u.id - WHERE p.id IN (SELECT pa.project_id - FROM project_authors pa - JOIN users uu - ON pa.user_id = uu.id - WHERE uu.id = ? - OR uu.username = ?) - GROUP BY p.id, - p.slug, - p.modrinth_id, - p.attributed_to - """; - } - - private static String selectAllByEvent() { - return """ - SELECT p.id, - p.slug, - p.modrinth_id, - p.attributed_to, - COALESCE(Group_concat(DISTINCT a.user_id), '') AS authors, - COALESCE(Group_concat(DISTINCT b.user_id), '') AS builders - FROM projects p - LEFT JOIN project_authors a - ON p.id = a.project_id - LEFT JOIN project_builders b - ON p.id = b.project_id - LEFT JOIN users u - ON a.user_id = u.id - LEFT JOIN submissions s - ON s.project_id = p.id - LEFT JOIN events e - ON e.id = s.event - WHERE e.id = ? or e.slug = ? - GROUP BY p.id, - p.slug, - p.modrinth_id, - p.attributed_to - """; - } - - private static DataResult validate(String id) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM projects WHERE id = ?")) { - prepared.setString(1, id); - ResultSet result = prepared.executeQuery(); - if (result != null && result.getBoolean(1)) - return DataResult.success(id); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return DataResult.error(() -> "Failed to get project with id '" + id + "'."); - } } diff --git a/src/main/java/net/modgarden/backend/data/event/Submission.java b/src/main/java/net/modgarden/backend/data/event/Submission.java index 4f5a68d..4ab1657 100644 --- a/src/main/java/net/modgarden/backend/data/event/Submission.java +++ b/src/main/java/net/modgarden/backend/data/event/Submission.java @@ -1,255 +1,42 @@ package net.modgarden.backend.data.event; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; -import com.mojang.serialization.JsonOps; +import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import de.mkammerer.snowflakeid.SnowflakeIdGenerator; -import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.util.ExtraCodecs; +import net.modgarden.backend.data.Platform; +import net.modgarden.backend.data.event.platform.DownloadUrlPlatform; +import net.modgarden.backend.data.event.platform.ModrinthPlatform; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; +import java.util.Map; + +import static java.util.Map.entry; +import static net.modgarden.backend.data.Platform.fromMapCodec; -// TODO: Potentially allow GitHub only submissions. Not necessarily now, but more notes on this will be placed in internal team chats. - Calico public record Submission(String id, String event, + long timeSubmitted, Project project, - String modrinthVersionId, - ZonedDateTime submitted) { - public static final SnowflakeIdGenerator ID_GENERATOR = SnowflakeIdGenerator.createDefault(3); - public static final Codec DIRECT_CODEC = RecordCodecBuilder.create(inst -> inst.group( + Platform platform) { + private static final Map> PLATFORM_MAP_CODECS = Map.ofEntries( + entry("modrinth", fromMapCodec(ModrinthPlatform.CODEC)), + entry("download_url", fromMapCodec(DownloadUrlPlatform.CODEC)) + ); + private static final Codec PLATFORM_CODEC = Codec.STRING.dispatch(Platform::getName, PLATFORM_MAP_CODECS::get); + + public static final Codec DIRECT_CODEC = RecordCodecBuilder.create(inst -> inst.group( Codec.STRING.fieldOf("id").forGetter(Submission::id), Event.ID_CODEC.fieldOf("event").forGetter(Submission::event), + Codec.LONG.fieldOf("time_submitted").forGetter(Submission::timeSubmitted), Project.DIRECT_CODEC.fieldOf("project").forGetter(Submission::project), - Codec.STRING.fieldOf("modrinth_version_id").forGetter(Submission::modrinthVersionId), - ExtraCodecs.ISO_DATE_TIME.fieldOf("submitted").forGetter(Submission::submitted) + PLATFORM_CODEC.fieldOf("platform").forGetter(Submission::platform) ).apply(inst, Submission::new)); public static final Codec ID_CODEC = Codec.STRING.validate(Submission::validate); - public static final Codec CODEC = ID_CODEC.xmap(Submission::innerQuery, Submission::id); - - public static void getSubmission(Context ctx) { - String path = ctx.pathParam("submission"); - if (!path.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + path + "'."); - ctx.status(422); - return; - } - Submission submission = innerQuery(path); - if (submission == null) { - ModGardenBackend.LOG.error("Could not find submission '{}'.", path); - ctx.result("Could not find submission '" + path + "'."); - ctx.status(404); - return; - } - - ModGardenBackend.LOG.debug("Successfully queried submission from path '{}'", path); - ctx.json(submission); - } - - public static void getSubmissionsByUser(Context ctx) { - String user = ctx.pathParam("user"); - if (!user.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + user + "'."); - ctx.status(422); - return; - } - var queryString = selectByUserStatement(); - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - PreparedStatement prepared = connection.prepareStatement(queryString); - prepared.setString(1, user); - prepared.setString(2, user); - ResultSet result = prepared.executeQuery(); - var submissions = new JsonArray(); - while (result.next()) { - var submission = new JsonObject(); - submission.addProperty("id", result.getString("id")); - submission.addProperty("event", result.getString("event")); - submission.addProperty("modrinth_version_id", result.getString("modrinth_version_id")); - submission.addProperty("submitted", result.getLong("submitted")); - - String projectId = result.getString("project_id"); - Project project = Project.queryFromId(projectId); - if (project == null) - throw new SQLException("Could not find project '" + projectId + "'."); - submission.add("project", Project.DIRECT_CODEC.encodeStart(JsonOps.INSTANCE, project).getOrThrow(SQLException::new)); - - submissions.add(submission); - } - ctx.json(submissions); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - } - - public static void getSubmissionsByEvent(Context ctx) { - String event = ctx.pathParam("event"); - if (!event.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + event + "'."); - ctx.status(422); - return; - } - var queryString = selectByEventStatement(); - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - PreparedStatement prepared = connection.prepareStatement(queryString); - prepared.setString(1, event); - prepared.setString(2, event); - ResultSet result = prepared.executeQuery(); - var submissions = new JsonArray(); - while (result.next()) { - var submission = new JsonObject(); - submission.addProperty("id", result.getString("id")); - submission.addProperty("event", result.getString("event")); - submission.addProperty("modrinth_version_id", result.getString("modrinth_version_id")); - submission.addProperty("submitted", result.getLong("submitted")); - - String projectId = result.getString("project_id"); - Project project = Project.queryFromId(projectId); - if (project == null) - throw new SQLException("Could not find project '" + projectId + "'."); - submission.add("project", Project.DIRECT_CODEC.encodeStart(JsonOps.INSTANCE, project).getOrThrow(SQLException::new)); - - submissions.add(submission); - } - ctx.json(submissions); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - } - - public static void getSubmissionsByUserAndEvent(Context ctx) { - String user = ctx.pathParam("user"); - String event = ctx.pathParam("event"); - if (!user.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + user + "'."); - ctx.status(422); - return; - } - if (!event.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + event + "'."); - ctx.status(422); - return; - } - var queryString = selectByUserAndEventStatement(); - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement eventStatement = connection.prepareStatement(queryString)) { - eventStatement.setString(1, user); - eventStatement.setString(2, user); - eventStatement.setString(3, event); - eventStatement.setString(4, event); - ResultSet result = eventStatement.executeQuery(); - var submissions = new JsonArray(); - while (result.next()) { - var submission = new JsonObject(); - submission.addProperty("id", result.getString("id")); - submission.addProperty("event", result.getString("event")); - submission.addProperty("modrinth_version_id", result.getString("modrinth_version_id")); - submission.addProperty("submitted", result.getLong("submitted")); - - String projectId = result.getString("project_id"); - Project project = Project.queryFromId(projectId); - if (project == null) - throw new SQLException("Could not find project '" + projectId + "'."); - submission.add("project", Project.DIRECT_CODEC.encodeStart(JsonOps.INSTANCE, project).getOrThrow(SQLException::new)); - - submissions.add(submission); - } - ctx.json(submissions); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - } - - public static Submission innerQuery(String id) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectStatement())) { - prepared.setString(1, id); - ResultSet result = prepared.executeQuery(); - if (!result.isBeforeFirst()) - return null; - - - String projectId = result.getString("project_id"); - Project project = Project.queryFromId(projectId); - if (project == null) - throw new SQLException("Could not find project '" + projectId + "'."); - - return new Submission( - result.getString("id"), - result.getString("event"), - project, - result.getString("modrinth_version_id"), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("submitted")), ZoneId.of("GMT")) - ); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return null; - } - - private static String selectStatement() { - return """ - SELECT s.id, s.project_id, s.event, s.modrinth_version_id, s.submitted - FROM - submissions s - WHERE - s.id = ? - GROUP BY - s.id, s.project_id, s.event, s.modrinth_version_id, s.submitted - """; - } - - private static String selectByUserStatement() { - return """ - SELECT s.id, s.project_id, s.event, s.modrinth_version_id, s.submitted - FROM submissions s - LEFT JOIN projects p on p.id = s.project_id - LEFT JOIN project_authors a on a.project_id = s.project_id - WHERE a.user_id IN ( - SELECT u.id - FROM users u - WHERE u.id = ? OR u.username = ? - ) - GROUP BY s.id - """; - } - - private static String selectByEventStatement() { - return """ - SELECT s.id, s.project_id, s.event, s.modrinth_version_id, s.submitted - FROM submissions s - LEFT JOIN events e on e.id = s.event - WHERE s.event = ? OR e.slug = ? - GROUP BY s.id - """; - } - - private static String selectByUserAndEventStatement() { - return """ - SELECT s.id, s.project_id, s.event, s.modrinth_version_id, s.submitted - FROM submissions s - LEFT JOIN projects p on p.id = s.project_id - LEFT JOIN project_authors a on a.project_id = s.project_id - WHERE a.user_id IN ( - SELECT u.id - FROM users u - WHERE u.id = ? OR u.username = ? - ) AND s.event IN ( - SELECT e.id - FROM events e - WHERE e.id = ? OR e.slug = ? - ) - GROUP BY s.id - """; - } private static DataResult validate(String id) { try (Connection connection = ModGardenBackend.createDatabaseConnection(); diff --git a/src/main/java/net/modgarden/backend/data/event/metadata/DraftMetadata.java b/src/main/java/net/modgarden/backend/data/event/metadata/DraftMetadata.java new file mode 100644 index 0000000..c2800df --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/event/metadata/DraftMetadata.java @@ -0,0 +1,22 @@ +package net.modgarden.backend.data.event.metadata; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.data.Metadata; + +public record DraftMetadata(String name) implements Metadata { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( + Codec.STRING.fieldOf("name").forGetter(DraftMetadata::name) + ).apply(inst, DraftMetadata::new)); + + @Override + public String getName() { + return "draft"; + } + + @Override + public MapCodec codec() { + return CODEC; + } +} diff --git a/src/main/java/net/modgarden/backend/data/event/metadata/ModMetadata.java b/src/main/java/net/modgarden/backend/data/event/metadata/ModMetadata.java new file mode 100644 index 0000000..10ff565 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/event/metadata/ModMetadata.java @@ -0,0 +1,37 @@ +package net.modgarden.backend.data.event.metadata; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.data.Metadata; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +public record ModMetadata(String modId, String name, @Nullable String description, String sourceUrl) implements Metadata { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( + Codec.STRING.fieldOf("mod_id").forGetter(ModMetadata::modId), + Codec.STRING.fieldOf("name").forGetter(ModMetadata::name), + Codec.STRING.optionalFieldOf("description").forGetter(ModMetadata::descriptionAsOptional), + Codec.STRING.fieldOf("source_url").forGetter(ModMetadata::sourceUrl) + ).apply(inst, ModMetadata::new)); + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private ModMetadata(String modId, String name, Optional description, String sourceUrl) { + this(modId, name, description.orElse(null), sourceUrl); + } + + private Optional descriptionAsOptional() { + return Optional.ofNullable(description); + } + + @Override + public String getName() { + return "mod"; + } + + @Override + public MapCodec codec() { + return CODEC; + } +} diff --git a/src/main/java/net/modgarden/backend/data/event/platform/DownloadUrlPlatform.java b/src/main/java/net/modgarden/backend/data/event/platform/DownloadUrlPlatform.java new file mode 100644 index 0000000..147ff62 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/event/platform/DownloadUrlPlatform.java @@ -0,0 +1,34 @@ +package net.modgarden.backend.data.event.platform; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.data.Platform; + +/// A platform for download URLs, useful for Git Releases without depending on a specific Git host. +/// +/// An example based on Variant Lib would be as follows. +/// ```json +/// { +/// "type": "download_url", +/// "download_url": "https://git.greenhouse.lgbt/Modding/variant-lib/releases/download/0.3.2+1.21.5/variantlib-fabric-0.3.2+1.21.5.jar" +/// } +/// ``` +/// +/// @param downloadUrl A direct download link to a mod JAR. +/// +public record DownloadUrlPlatform(String downloadUrl) implements Platform { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( + Codec.STRING.fieldOf("download_url").forGetter(DownloadUrlPlatform::downloadUrl) + ).apply(inst, DownloadUrlPlatform::new)); + + @Override + public String getName() { + return "download_url"; + } + + @Override + public MapCodec getCodec() { + return CODEC; + } +} diff --git a/src/main/java/net/modgarden/backend/data/event/platform/ModrinthPlatform.java b/src/main/java/net/modgarden/backend/data/event/platform/ModrinthPlatform.java new file mode 100644 index 0000000..9f7ca3d --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/event/platform/ModrinthPlatform.java @@ -0,0 +1,37 @@ +package net.modgarden.backend.data.event.platform; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.data.Platform; + +/// A platform for Modrinth releases, linking to a specific version as part of a Modrinth project. +/// +/// An example based on Variant Lib would be as follows. +/// ```json +/// { +/// "type": "modrinth", +/// "project_id": "LQCrGzOR", +/// "version_id": "Qt7I0urr" +/// } +/// ``` +/// +/// @param projectId The project ID of the Modrinth project. +/// @param versionId The version ID to pull from Modrinth for the mod JAR. +/// +public record ModrinthPlatform(String projectId, String versionId) implements Platform { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( + Codec.STRING.fieldOf("project_id").forGetter(ModrinthPlatform::projectId), + Codec.STRING.fieldOf("version_id").forGetter(ModrinthPlatform::versionId) + ).apply(inst, ModrinthPlatform::new)); + + @Override + public String getName() { + return "modrinth"; + } + + @Override + public MapCodec getCodec() { + return CODEC; + } +} diff --git a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFix.java b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFix.java index 4a8d73c..b14ee8a 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFix.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFix.java @@ -1,7 +1,10 @@ package net.modgarden.backend.data.fixer; +import org.jetbrains.annotations.Nullable; + import java.sql.Connection; import java.sql.SQLException; +import java.util.function.Consumer; public abstract class DatabaseFix { private final int versionToFixFrom; @@ -10,11 +13,19 @@ public DatabaseFix(int versionToFixFrom) { this.versionToFixFrom = versionToFixFrom; } - public abstract void fix(Connection connection) throws SQLException; + /// Data-fix the database. + /// + /// @param connection a common connection between datafixers. + /// @return a consumer with a fresh, datafixer-specific connection useful only for dropping tables. + public abstract @Nullable Consumer fix(Connection connection) throws SQLException; - protected void fixInternal(Connection connection, int currentSchemaVersion) throws SQLException { + protected Consumer fixInternal(Connection connection, int currentSchemaVersion) throws SQLException { if (versionToFixFrom < currentSchemaVersion) - return; - fix(connection); + return null; + return fix(connection); + } + + public int getVersionToFixFrom() { + return this.versionToFixFrom; } } diff --git a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java index 0e1e2ed..4becf4a 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java @@ -2,40 +2,73 @@ import it.unimi.dsi.fastutil.objects.ObjectArrayList; import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.fixer.fix.V1ToV2; -import net.modgarden.backend.data.fixer.fix.V2ToV3; -import net.modgarden.backend.data.fixer.fix.V3ToV4; -import net.modgarden.backend.data.fixer.fix.V4ToV5; +import net.modgarden.backend.data.fixer.fix.*; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collections; import java.util.List; +import java.util.function.Consumer; public class DatabaseFixer { private static final List FIXES = new ObjectArrayList<>(); public static void createFixers() { - FIXES.add(new V1ToV2()); - FIXES.add(new V2ToV3()); - FIXES.add(new V3ToV4()); - FIXES.add(new V4ToV5()); + Collections.addAll( + FIXES, + new V1ToV2(), + new V2ToV3(), + new V3ToV4(), + new V4ToV5(), + new V5ToV6() + ); + } + + public static int getSchemaVersion() { + if (FIXES.isEmpty()) { + return -1; + } + return FIXES.getLast().getVersionToFixFrom() + 1; } public static void fixDatabase() { + long startTime = System.currentTimeMillis(); + int version = -1; try (Connection connection = ModGardenBackend.createDatabaseConnection(); PreparedStatement schemaVersion = connection.prepareStatement("SELECT version FROM schema")) { ResultSet query = schemaVersion.executeQuery(); - int version = query.getInt(1); - if (version >= ModGardenBackend.DATABASE_SCHEMA_VERSION) + version = query.getInt(1); + int lastVersion = getSchemaVersion(); + if (lastVersion == -1 || version > lastVersion) { + throw new IllegalStateException("Schema version is invalid! Got " + lastVersion + ", " + version + " in the database"); + } + if (version == lastVersion) return; - for (DatabaseFix fix : FIXES) { - fix.fixInternal(connection, version); - } } catch (Exception ex) { ModGardenBackend.LOG.error("Failed to fix data: ", ex); } + + for (DatabaseFix fix : FIXES) { + Consumer dropper = null; + try (Connection connection = ModGardenBackend.createDatabaseConnection()) { + dropper = fix.fixInternal(connection, version); + } catch (SQLException ex) { + ModGardenBackend.LOG.error("Failed to fix data: ", ex); + } + + try (Connection connection = ModGardenBackend.createDatabaseConnection()) { + if (dropper != null) { + dropper.accept(connection); + } + } catch (SQLException ex) { + ModGardenBackend.LOG.error("Failed to fix data: ", ex); + } + } + long endTime = System.currentTimeMillis(); + ModGardenBackend.LOG.debug("Data-fixer took {}ms", endTime - startTime); } } diff --git a/src/main/java/net/modgarden/backend/data/fixer/fix/V1ToV2.java b/src/main/java/net/modgarden/backend/data/fixer/fix/V1ToV2.java index e7dc6e9..8391535 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V1ToV2.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V1ToV2.java @@ -1,10 +1,12 @@ package net.modgarden.backend.data.fixer.fix; import net.modgarden.backend.data.fixer.DatabaseFix; +import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement; +import java.util.function.Consumer; public class V1ToV2 extends DatabaseFix { public V1ToV2() { @@ -12,7 +14,7 @@ public V1ToV2() { } @Override - public void fix(Connection connection) throws SQLException { + public @Nullable Consumer fix(Connection connection) throws SQLException { Statement addDiscordRoleStatement = connection.createStatement(); addDiscordRoleStatement.execute("ALTER TABLE events ADD COLUMN discord_role_id TEXT NOT NULL"); @@ -21,5 +23,6 @@ public void fix(Connection connection) throws SQLException { Statement dropLoaderVersionStatement = connection.createStatement(); dropLoaderVersionStatement.execute("ALTER TABLE events DROP COLUMN loader_version"); + return null; } } diff --git a/src/main/java/net/modgarden/backend/data/fixer/fix/V2ToV3.java b/src/main/java/net/modgarden/backend/data/fixer/fix/V2ToV3.java index 145f3dd..8c1f79c 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V2ToV3.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V2ToV3.java @@ -1,10 +1,12 @@ package net.modgarden.backend.data.fixer.fix; import net.modgarden.backend.data.fixer.DatabaseFix; +import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement; +import java.util.function.Consumer; public class V2ToV3 extends DatabaseFix { public V2ToV3() { @@ -12,7 +14,7 @@ public V2ToV3() { } @Override - public void fix(Connection connection) throws SQLException { + public @Nullable Consumer fix(Connection connection) throws SQLException { Statement discordRoleIdToNotNullStatement = connection.createStatement(); discordRoleIdToNotNullStatement.addBatch("ALTER TABLE events RENAME COLUMN discord_role_id TO temp"); discordRoleIdToNotNullStatement.addBatch("ALTER TABLE events ADD COLUMN discord_role_id TEXT"); @@ -22,5 +24,6 @@ public void fix(Connection connection) throws SQLException { Statement addRegistrationTimeStatement = connection.createStatement(); addRegistrationTimeStatement.execute("ALTER TABLE events ADD COLUMN registration_time INTEGER NOT NULL"); + return null; } } diff --git a/src/main/java/net/modgarden/backend/data/fixer/fix/V3ToV4.java b/src/main/java/net/modgarden/backend/data/fixer/fix/V3ToV4.java index 2d69ca6..5a685f2 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V3ToV4.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V3ToV4.java @@ -1,10 +1,12 @@ package net.modgarden.backend.data.fixer.fix; import net.modgarden.backend.data.fixer.DatabaseFix; +import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement; +import java.util.function.Consumer; public class V3ToV4 extends DatabaseFix { public V3ToV4() { @@ -12,8 +14,9 @@ public V3ToV4() { } @Override - public void fix(Connection connection) throws SQLException { + public @Nullable Consumer fix(Connection connection) throws SQLException { Statement addFreezeTimeStatement = connection.createStatement(); addFreezeTimeStatement.execute("ALTER TABLE events ADD COLUMN freeze_time INTEGER NOT NULL"); + return null; } } diff --git a/src/main/java/net/modgarden/backend/data/fixer/fix/V4ToV5.java b/src/main/java/net/modgarden/backend/data/fixer/fix/V4ToV5.java index 0791a8e..4043f0c 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V4ToV5.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V4ToV5.java @@ -1,9 +1,11 @@ package net.modgarden.backend.data.fixer.fix; import net.modgarden.backend.data.fixer.DatabaseFix; +import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.SQLException; +import java.util.function.Consumer; public class V4ToV5 extends DatabaseFix { public V4ToV5() { @@ -11,7 +13,7 @@ public V4ToV5() { } @Override - public void fix(Connection connection) throws SQLException { + public @Nullable Consumer fix(Connection connection) throws SQLException { var statement = connection.createStatement(); statement.addBatch("CREATE TABLE IF NOT EXISTS team_invites (" + "code TEXT NOT NULL," + @@ -24,5 +26,6 @@ public void fix(Connection connection) throws SQLException { "PRIMARY KEY (code)" + ")"); statement.executeBatch(); + return null; } } diff --git a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java new file mode 100644 index 0000000..df5bed4 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -0,0 +1,462 @@ +package net.modgarden.backend.data.fixer.fix; + +import net.modgarden.backend.data.event.metadata.ModMetadata; +import net.modgarden.backend.data.fixer.DatabaseFix; +import net.modgarden.backend.util.MetadataUtils; +import org.jetbrains.annotations.Nullable; +import org.sqlite.Function; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.function.Consumer; + +public class V5ToV6 extends DatabaseFix { + public V5ToV6() { + super(5); + } + + @Override + public @Nullable Consumer fix(Connection connection) throws SQLException { + var statement = connection.createStatement(); + + // temp functions for the datafixer + Function.create( + connection, "clean_slug_mg", new Function() { + @Override + protected void xFunc() throws SQLException { + String slug = this.value_text(0); + this.result(slug.replace("mod-garden-", "")); + } + } + ); + + statement.addBatch("PRAGMA foreign_keys = ON"); + + statement.addBatch("ALTER TABLE users RENAME TO users_old"); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + created INTEGER NOT NULL, + permissions INTEGER NOT NULL, + PRIMARY KEY(id) + ) + """); + statement.addBatch(""" + INSERT INTO users (id, username, created, permissions) + SELECT id, username, created, permissions from users_old + """); + + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_bios ( + user_id TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + pronouns TEXT, + description TEXT, + avatar_url TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_bio_fields ( + user_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_value TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE + ) + """); + statement.addBatch(""" + CREATE UNIQUE INDEX idx_user_id_field_name ON user_bio_fields(field_name, field_value) + """); + + statement.addBatch(""" + INSERT INTO user_bios (user_id, display_name, pronouns, avatar_url) + SELECT id, display_name, pronouns, avatar_url FROM users_old + """); + + // Events modification is above all event related operations because order matters when executing SQL actions. + statement.addBatch(""" + ALTER TABLE events ADD event_type_slug TEXT NOT NULL DEFAULT 'mod-garden' + """); + statement.addBatch(""" + ALTER TABLE events RENAME COLUMN registration_time TO registration_open_time + """); + statement.addBatch(""" + ALTER TABLE events ADD registration_close_time INTEGER NOT NULL DEFAULT 1748131200000 + """); + statement.addBatch(""" + ALTER TABLE events RENAME TO events_old + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS events ( + id TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + event_type_slug TEXT NOT NULL, + display_name TEXT NOT NULL, + minecraft_version TEXT NOT NULL, + loader TEXT NOT NULL, + registration_open_time INTEGER NOT NULL, + registration_close_time INTEGER NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + freeze_time INTEGER NOT NULL, + PRIMARY KEY (id) + ) + """); + statement.addBatch(""" + INSERT INTO events (id, slug, event_type_slug, display_name, minecraft_version, loader, registration_open_time, registration_close_time, start_time, end_time, freeze_time) + SELECT id, slug, event_type_slug, display_name, minecraft_version, loader, registration_open_time, registration_close_time, start_time, end_time, freeze_time from events_old + """); + + statement.addBatch("ALTER TABLE projects RENAME TO projects_old"); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS projects ( + id TEXT UNIQUE NOT NULL, + PRIMARY KEY (id) + ) + """); + statement.addBatch(""" + INSERT INTO projects (id) + SELECT id FROM projects_old + """); + + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_draft_metadata ( + project_id TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (project_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_mod_metadata ( + project_id TEXT UNIQUE NOT NULL, + mod_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + source_url TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (project_id) + ) + """); + + // For similar reasons to the below, handle projects and submissions above other content too. + statement.addBatch("ALTER TABLE submissions RENAME TO submissions_old"); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS submissions ( + id TEXT UNIQUE NOT NULL, + event TEXT NOT NULL, + project_id TEXT NOT NULL, + submitted INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (event) REFERENCES events(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY(id) + ) + """); + // Update submissions old instead of submissions to make sure submissions_mr shares the correct data-fixed IDs. + statement.addBatch(""" + UPDATE submissions_old + SET id = generate_natural_id('submissions', id, NULL, 5) + """); + statement.addBatch(""" + INSERT INTO submissions (id, event, project_id, submitted) + SELECT id, event, project_id, submitted from submissions_old + """); + + // Use submissions_old since it has not yet been deleted. + statement.addBatch("CREATE TABLE submissions_mr AS SELECT * FROM submissions_old"); + statement.addBatch("ALTER TABLE submissions_mr ADD COLUMN modrinth_id TEXT"); + + statement.addBatch(""" + UPDATE submissions_mr + SET modrinth_id = ( + SELECT modrinth_id FROM projects_old WHERE submissions_mr.project_id = projects_old.id + ) + """); + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS submission_type_modrinth ( + submission_id TEXT NOT NULL, + modrinth_id TEXT NOT NULL, + version_id TEXT NOT NULL, + FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (submission_id) + ) + """); + statement.addBatch(""" + INSERT INTO submission_type_modrinth (submission_id, modrinth_id, version_id) + SELECT id, modrinth_id, modrinth_version_id FROM submissions_mr + WHERE modrinth_id NOT NULL + """); + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_keys ( + uuid BLOB NOT NULL, + user_id TEXT NOT NULL, + hash TEXT NOT NULL, + expires INTEGER NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_key_scopes ( + uuid BLOB NOT NULL, + scope TEXT NOT NULL CHECK (scope in ('project', 'user')), + project_id TEXT, + permissions INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (uuid) REFERENCES api_keys(uuid) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS passwords ( + user_id TEXT NOT NULL, + hash TEXT NOT NULL, + last_updated INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_integration_modrinth ( + user_id TEXT NOT NULL, + modrinth_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_integration_discord ( + user_id TEXT NOT NULL, + discord_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (user_id) + ) + """); + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_roles ( + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + role_name TEXT NOT NULL DEFAULT 'Member', + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE + ) + """); + + statement.addBatch(""" + CREATE UNIQUE INDEX idx_project_roles_two_ids ON project_roles(project_id, user_id) + """); + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS event_integration_discord ( + id TEXT UNIQUE NOT NULL, + role_id TEXT NOT NULL, + FOREIGN KEY (id) REFERENCES events(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (id) + ) + """); + statement.addBatch(""" + INSERT INTO event_integration_discord (id, role_id) + SELECT id, discord_role_id FROM events_old + """); + + statement.addBatch(""" + INSERT INTO user_integration_modrinth (user_id, modrinth_id) + SELECT id, modrinth_id FROM users_old + WHERE modrinth_id NOT NULL + """); + + statement.addBatch(""" + INSERT INTO user_integration_discord (user_id, discord_id) + SELECT id, discord_id FROM users_old + """); + + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_roles_temp ( + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 1, + role_name TEXT NOT NULL DEFAULT 'Member', + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE + ) + """); + + statement.addBatch(""" + INSERT OR REPLACE INTO project_roles_temp (project_id, user_id) + SELECT project_id, user_id FROM project_authors + """); + + statement.addBatch(""" + INSERT INTO project_roles (project_id, user_id, permissions, role_name) + SELECT project_id, user_id, permissions, role_name FROM project_roles_temp + """); + + statement.addBatch("ALTER TABLE minecraft_accounts RENAME TO minecraft_accounts_old"); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS user_integration_minecraft ( + uuid TEXT UNIQUE NOT NULL, + user_id TEXT UNIQUE NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + INSERT INTO user_integration_minecraft (uuid, user_id) + SELECT uuid, user_id FROM minecraft_accounts_old + """); + + statement.addBatch("ALTER TABLE award_instances RENAME TO award_instances_old"); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS award_instances ( + award_id TEXT UNIQUE NOT NULL, + awarded_to TEXT NOT NULL, + custom_data TEXT, + submission_id TEXT, + tier_override TEXT CHECK (tier_override in ('COMMON', 'UNCOMMON', 'RARE', 'LEGENDARY')), + FOREIGN KEY (award_id) REFERENCES awards(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (awarded_to) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (award_id, awarded_to) + ) + """); + statement.addBatch(""" + INSERT INTO award_instances (award_id, awarded_to, custom_data, submission_id, tier_override) + SELECT award_id, awarded_to, custom_data, submission_id, tier_override FROM award_instances_old + """); + + statement.addBatch("ALTER TABLE team_invites RENAME TO team_invites_old"); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS team_invites ( + code TEXT NOT NULL, + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + expires INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'Member', + permissions INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (code) + ) + """); + statement.addBatch(""" + INSERT INTO team_invites (code, project_id, user_id, expires, role) + SELECT code, project_id, user_id, expires, role FROM team_invites_old + """); + + statement.addBatch(""" + UPDATE users + SET id = generate_natural_id('users', id, NULL, 5) + """); + + statement.addBatch(""" + UPDATE users + SET id = 'mgacc', permissions = 1 + WHERE username == 'mod_garden' + """); + statement.addBatch(""" + UPDATE user_bios + SET pronouns = 'they/it' + WHERE user_id = 'mgacc' + """); + + statement.addBatch(""" + INSERT INTO users VALUES ('grbot', 'gardenbot', unix_millis(), 1) + """); + statement.addBatch(""" + UPDATE user_bios + SET display_name = 'GardenBot', pronouns = 'it/its' + WHERE user_id = 'grbot' + """); + + statement.addBatch(""" + INSERT INTO users VALUES ('abcde', 'tiny_pineapple', unix_millis(), 0) + """); + statement.addBatch(""" + UPDATE user_bios + SET display_name = 'Tiny Pineapple', pronouns = 'it/its' + WHERE user_id = 'abcde' + """); + + statement.addBatch(""" + UPDATE projects + SET id = generate_natural_id('projects', id, NULL, 5) + """); + + statement.addBatch(""" + UPDATE events + SET id = generate_natural_id('events', id, NULL, 5) + """); + statement.addBatch(""" + UPDATE events + SET slug = clean_slug_mg(slug) + """); + + statement.executeBatch(); + + var modrinthSubmissionsStatement = connection.prepareStatement(""" + SELECT s.project_id, mr.modrinth_id, mr.version_id + FROM submission_type_modrinth mr + INNER JOIN submissions s ON s.id = mr.submission_id + """); + var projectMetadataInsertStatement = connection.prepareStatement(""" + INSERT INTO project_mod_metadata (project_id, mod_id, name, description, source_url) + VALUES (?, ?, ?, ?, ?) + """); + var modrinthSubmissionsResult = modrinthSubmissionsStatement.executeQuery(); + + if (modrinthSubmissionsResult.isBeforeFirst()) { + while (modrinthSubmissionsResult.next()) { + String projectId = modrinthSubmissionsResult.getString("project_id"); + String modrinthId = modrinthSubmissionsResult.getString("modrinth_id"); + String modrinthVersionId = modrinthSubmissionsResult.getString("version_id"); + + try { + var modrinthMetadata = MetadataUtils.getMetadataFromModrinth(modrinthId, modrinthVersionId); + if (!(modrinthMetadata instanceof ModMetadata( + String modId, String name, String description, String sourceUrl + ))) continue; + projectMetadataInsertStatement.setString(1, projectId); + projectMetadataInsertStatement.setString(2, modId); + projectMetadataInsertStatement.setString(3, name); + projectMetadataInsertStatement.setString(4, description); + projectMetadataInsertStatement.setString(5, sourceUrl); + projectMetadataInsertStatement.executeUpdate(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + return postConnections -> { + try { + var postStatements = postConnections.createStatement(); + postStatements.addBatch("PRAGMA foreign_keys = ON"); + + postStatements.addBatch("DROP TABLE submissions_old"); + postStatements.addBatch("DROP TABLE submissions_mr"); + postStatements.addBatch("DROP TABLE project_builders"); + postStatements.addBatch("DROP TABLE project_authors"); + postStatements.addBatch("DROP TABLE project_roles_temp"); + postStatements.addBatch("DROP TABLE minecraft_accounts_old"); + postStatements.addBatch("DROP TABLE award_instances_old"); + postStatements.addBatch("DROP TABLE team_invites_old"); + postStatements.addBatch("DROP TABLE projects_old"); + postStatements.addBatch("DROP TABLE users_old"); + postStatements.addBatch("DROP TABLE events_old"); + postStatements.executeBatch(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }; + } +} diff --git a/src/main/java/net/modgarden/backend/data/profile/MinecraftAccount.java b/src/main/java/net/modgarden/backend/data/profile/MinecraftAccount.java deleted file mode 100644 index 58df3cb..0000000 --- a/src/main/java/net/modgarden/backend/data/profile/MinecraftAccount.java +++ /dev/null @@ -1,102 +0,0 @@ -package net.modgarden.backend.data.profile; - -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.mojang.serialization.Codec; -import com.mojang.serialization.JavaOps; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.util.ExtraCodecs; - -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.URI; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.Locale; -import java.util.UUID; - -public record MinecraftAccount(UUID uuid, - String userId) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - ExtraCodecs.UUID_CODEC.fieldOf("uuid").forGetter(MinecraftAccount::uuid), - Codec.STRING.fieldOf("user_id").forGetter(MinecraftAccount::userId) - ).apply(inst, MinecraftAccount::new)); - - public static void getAccount(Context ctx) { - String path = ctx.pathParam("mcaccount"); - if (!path.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + path + "'."); - ctx.status(422); - return; - } - MinecraftAccount account = query(path.toLowerCase(Locale.ROOT)); - if (account == null) { - ModGardenBackend.LOG.debug("Could not find Minecraft account '{}'.", path); - ctx.result("Could not find Minecraft account '" + path + "'."); - ctx.status(404); - return; - } - - ModGardenBackend.LOG.debug("Successfully queried minecraft account from path '{}'", path); - ctx.json(account); - } - - public static MinecraftAccount query(String path) { - MinecraftAccount account = queryFromUsername(path); - - if (account == null) - account = queryFromUuid(path); - - return account; - } - - private static MinecraftAccount queryFromUsername(String username) { - try { - String uuid = getUuidFromUsername(username); - if (uuid != null) - return queryFromUuid(uuid); - return null; - } catch (IOException | InterruptedException ex) { - return null; - } - } - - private static String getUuidFromUsername(String username) throws IOException, InterruptedException { - var req = HttpRequest.newBuilder(URI.create("https://api.mojang.com/users/profiles/minecraft/" + username)) - .build(); - HttpResponse stream = ModGardenBackend.HTTP_CLIENT.send(req, HttpResponse.BodyHandlers.ofInputStream()); - if (stream.statusCode() != 200) - return null; - try (InputStreamReader reader = new InputStreamReader(stream.body())) { - JsonElement element = JsonParser.parseReader(reader); - if (!element.isJsonObject()) - return null; - return element.getAsJsonObject().getAsJsonPrimitive("id").toString(); - } - } - - private static MinecraftAccount queryFromUuid(String uuid) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement("SELECT * FROM minecraft_accounts WHERE uuid=?")) { - prepared.setString(1, uuid); - ResultSet result = prepared.executeQuery(); - if (!result.isBeforeFirst()) - return null; - var decodedUUID = ExtraCodecs.UUID_CODEC.decode(JavaOps.INSTANCE, result.getString("uuid")).getOrThrow().getFirst(); - return new MinecraftAccount( - decodedUUID, - result.getString("user_id") - ); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return null; - } -} diff --git a/src/main/java/net/modgarden/backend/data/profile/User.java b/src/main/java/net/modgarden/backend/data/profile/User.java deleted file mode 100644 index 1702434..0000000 --- a/src/main/java/net/modgarden/backend/data/profile/User.java +++ /dev/null @@ -1,299 +0,0 @@ -package net.modgarden.backend.data.profile; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.JsonOps; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import de.mkammerer.snowflakeid.SnowflakeIdGenerator; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.Permission; -import net.modgarden.backend.data.award.AwardInstance; -import net.modgarden.backend.data.event.Event; -import net.modgarden.backend.data.event.Project; -import net.modgarden.backend.oauth.OAuthService; -import net.modgarden.backend.util.ExtraCodecs; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.http.HttpResponse; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.*; - -public record User(String id, - String username, - String displayName, - Optional avatarUrl, - Optional pronouns, - String discordId, - Optional modrinthId, - ZonedDateTime created, - List projects, - List events, - List minecraftAccounts, - List awards, - List permissions) { - public static final SnowflakeIdGenerator ID_GENERATOR = SnowflakeIdGenerator.createDefault(0); - - public static final String USERNAME_REGEX = "^(?=.{3,32}$)[a-z_0-9]+?$"; - - public static final Codec DIRECT_CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("id").forGetter(User::id), - Codec.STRING.fieldOf("username").forGetter(User::username), - Codec.STRING.fieldOf("display_name").forGetter(User::displayName), - Codec.STRING.optionalFieldOf("avatar_url").forGetter(User::avatarUrl), - Codec.STRING.optionalFieldOf("pronouns").forGetter(User::pronouns), - Codec.STRING.fieldOf("discord_id").forGetter(User::discordId), - Codec.STRING.optionalFieldOf("modrinth_id").forGetter(User::modrinthId), - ExtraCodecs.ISO_DATE_TIME.fieldOf("created").forGetter(User::created), - Project.ID_CODEC.listOf().fieldOf("projects").forGetter(User::projects), - Event.ID_CODEC.listOf().fieldOf("events").forGetter(User::events), - ExtraCodecs.UUID_CODEC.listOf().fieldOf("minecraft_accounts").forGetter(User::minecraftAccounts), - AwardInstance.UserValues.CODEC.listOf().fieldOf("awards").forGetter(User::awards), - Permission.LIST_CODEC.fieldOf("permissions").forGetter(User::permissions) - ).apply(inst, User::new)); - public static final Codec ID_CODEC = Codec.STRING.validate(User::validate); - public static final Codec CODEC = ID_CODEC.xmap(User::queryFromId, user -> user.id); - - public static void getUser(Context ctx) { - String path = ctx.pathParam("user"); - String service = ctx.queryParam("service"); - if (!path.matches(ModGardenBackend.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + path + "'."); - ctx.status(422); - return; - } - - String serviceEndString = switch (service) { - case "modrinth" -> "Modrinth"; - case "discord" -> "Discord"; - case null, default -> "Mod Garden"; - }; - - User user = query(path, service); - if (user == null) { - ModGardenBackend.LOG.debug("Could not find user '{}'.", path); - ctx.result("Could not find user '" + path + "' from service '" + serviceEndString + "'."); - ctx.status(404); - return; - } - ModGardenBackend.LOG.debug("Successfully queried user from path '{}' from service '{}'.", path, serviceEndString); - ctx.json(user); - } - - @Nullable - public static User query(String path, - @Nullable String service) { - User user; - - if ("modrinth".equalsIgnoreCase(service)) { - user = queryFromModrinthUsername(path.toLowerCase(Locale.ROOT)); - if (user == null) { - return queryFromModrinthId(path); - } - } else if ("discord".equalsIgnoreCase(service)) { - user = queryFromDiscordUsername(path.toLowerCase(Locale.ROOT)); - if (user == null) { - return queryFromDiscordId(path); - } - } else { - user = queryFromUsername(path); - if (user == null) { - return queryFromId(path); - } - } - return user; - } - - private static User queryFromUsername(String username) { - return innerQuery("username = ?", username); - } - - private static User queryFromId(String id) { - return innerQuery("id = ?", id); - } - - private static User queryFromDiscordId(String discordId) { - return innerQuery("discord_id = ?", discordId); - } - - private static User queryFromModrinthId(String modrinthId) { - return innerQuery("modrinth_id = ?", modrinthId); - } - - private static User queryFromDiscordUsername(String discordUsername) { - try { - String usernameToId = getUserDiscordId(discordUsername); - if (usernameToId == null) - return null; - return queryFromDiscordId(usernameToId); - } catch (IOException | InterruptedException ex) { - return null; - } - } - - private static User queryFromModrinthUsername(String modrinthUsername) { - try { - String usernameToId = getUserModrinthId(modrinthUsername); - if (usernameToId == null) - return null; - return queryFromModrinthId(usernameToId); - } catch (IOException | InterruptedException ex) { - return null; - } - } - - private static User innerQuery(String whereStatement, String id) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectStatement(whereStatement))) { - prepared.setString(1, id); - ResultSet result = prepared.executeQuery(); - if (!result.isBeforeFirst()) - return null; - - var projectJson = result.getString("projects"); // Array of strings - var eventJson = result.getString("events"); // Array of strings - var minecraftAccountJson = result.getString("minecraft_accounts"); // Array of UUIDs - var awardJson = result.getString("awards"); // Array of award instance user values - - List projects = result.getString("projects").isEmpty() ? List.of() : List.of(projectJson.split(",")); - List events = result.getString("events").isEmpty() ? List.of() : List.of(eventJson.split(",")); - - List minecraftAccounts = ExtraCodecs.UUID_CODEC.listOf().decode(JsonOps.INSTANCE, JsonParser.parseString(minecraftAccountJson)).getOrThrow().getFirst(); - List awards = AwardInstance.UserValues.CODEC.listOf().decode(JsonOps.INSTANCE, JsonParser.parseString(awardJson)).getOrThrow().getFirst(); - - return new User( - result.getString("id"), - result.getString("username"), - result.getString("display_name"), - Optional.ofNullable(result.getString("avatar_url")), - Optional.ofNullable(result.getString("pronouns")), - result.getString("discord_id"), - Optional.ofNullable(result.getString("modrinth_id")), - ZonedDateTime.ofInstant(Instant.ofEpochMilli(result.getLong("created")), ZoneId.of("GMT")), - projects, - events, - minecraftAccounts, - awards, - Permission.fromLong(result.getLong("permissions")) - ); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return null; - } - - private static DataResult validate(String id) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM users WHERE id = ?")) { - prepared.setString(1, id); - ResultSet result = prepared.executeQuery(); - if (result != null && result.getBoolean(1)) - return DataResult.success(id); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return DataResult.error(() -> "Failed to get user with id '" + id + "'."); - } - - private static String getUserModrinthId(String modrinthUsername) throws IOException, InterruptedException { - var modrinthClient = OAuthService.MODRINTH.authenticate(); - var stream = modrinthClient.get("v2/user/" + modrinthUsername, HttpResponse.BodyHandlers.ofInputStream()); - if (stream.statusCode() != 200) - return null; - try (InputStreamReader reader = new InputStreamReader(stream.body())) { - JsonElement element = JsonParser.parseReader(reader); - if (!element.isJsonObject()) - return null; - return element.getAsJsonObject().getAsJsonPrimitive("id").getAsString(); - } - } - - private static String getUserDiscordId(String discordUsername) throws IOException, InterruptedException { - var discordClient = OAuthService.DISCORD.authenticate(); - var stream = discordClient.get("guilds/1266288344644452363/members/search?query=" + discordUsername, HttpResponse.BodyHandlers.ofInputStream()); - if (stream.statusCode() != 200) - return null; - try (InputStreamReader reader = new InputStreamReader(stream.body())) { - JsonElement element = JsonParser.parseReader(reader); - if (!element.isJsonArray() || element.getAsJsonArray().isEmpty()) { - return null; - } - for (JsonElement userElement : element.getAsJsonArray()) { - if (!userElement.isJsonObject()) { - return null; - } - JsonObject userObject = userElement.getAsJsonObject(); - if ( - !userObject.has("user") || - !userObject.getAsJsonObject("user").has("id") || - !userObject.getAsJsonObject("user").getAsJsonPrimitive("id").isString() || - !userObject.getAsJsonObject("user").has("username") || - !userObject.getAsJsonObject("user").getAsJsonPrimitive("username").isString() || - !discordUsername.equals(userObject.getAsJsonObject("user").getAsJsonPrimitive("username").getAsString()) - ) { - return null; - } - return userElement.getAsJsonObject().getAsJsonObject("user").getAsJsonPrimitive("id").getAsString(); - } - return null; - } - } - - private static String selectStatement(String whereStatement) { - return "SELECT " + - "u.id, " + - "u.username, " + - "u.display_name, " + - "u.avatar_url, " + - "u.pronouns, " + - "u.discord_id, " + - "u.modrinth_id, " + - "u.created, " + - "u.permissions, " + - "CASE " + - "WHEN p.id NOT NULL THEN group_concat(DISTINCT p.id) " + - "ELSE '' " + - "END AS projects, " + - "CASE " + - "WHEN e.id NOT NULL THEN group_concat(DISTINCT e.id) " + - "ELSE '' " + - "END AS events, " + - "CASE " + - "WHEN ma.uuid NOT NULL THEN json_group_array(DISTINCT ma.uuid) " + - "ELSE json_array() " + - "END AS minecraft_accounts, " + - "CASE " + - "WHEN ai.award_id NOT NULL THEN json_group_array(DISTINCT json_object('award_id', ai.award_id, 'custom_data', ai.custom_data)) " + - "ELSE json_array() " + - "END AS awards " + - "FROM " + - "users u " + - "LEFT JOIN " + - "project_authors a ON u.id = a.user_id " + - "LEFT JOIN " + - "projects p ON p.id = a.project_id " + - "LEFT JOIN " + - "submissions s ON p.id = s.project_id " + - "LEFT JOIN " + - "events e ON s.event = e.id " + - "LEFT JOIN " + - "minecraft_accounts ma ON u.id = ma.user_id " + - "LEFT JOIN " + - "award_instances ai ON u.id = ai.awarded_to " + - "WHERE " + - "u." + whereStatement + " " + - "GROUP BY " + - "u.id, u.username, u.display_name, u.discord_id, u.modrinth_id, u.created, u.permissions"; - } -} diff --git a/src/main/java/net/modgarden/backend/data/user/User.java b/src/main/java/net/modgarden/backend/data/user/User.java new file mode 100644 index 0000000..e27604c --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/user/User.java @@ -0,0 +1,69 @@ +package net.modgarden.backend.data.user; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.Integration; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.data.award.AwardInstance; +import net.modgarden.backend.data.event.Event; +import net.modgarden.backend.data.event.Project; +import net.modgarden.backend.data.user.integration.DiscordIntegration; +import net.modgarden.backend.data.user.integration.MinecraftIntegration; +import net.modgarden.backend.data.user.integration.ModrinthIntegration; +import net.modgarden.backend.util.ExtraCodecs; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static net.modgarden.backend.data.Integration.fromCodec; + +public record User( + String id, + String username, + Instant created, + List projects, + List events, + List awards, + Permissions permissions, + Map integrations +) { + private static final Map> INTEGRATION_CODECS = Map.ofEntries( + entry("modrinth", fromCodec(ModrinthIntegration.CODEC)), + entry("discord", fromCodec(DiscordIntegration.CODEC)), + entry("minecraft", fromCodec(MinecraftIntegration.CODEC)) + ); + + public static final Codec DIRECT_CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("id").forGetter(User::id), + Codec.STRING.fieldOf("username").forGetter(User::username), + ExtraCodecs.INSTANT_CODEC.fieldOf("created").forGetter(User::created), + Project.ID_CODEC.listOf().fieldOf("projects").forGetter(User::projects), + Event.ID_CODEC.listOf().fieldOf("events").forGetter(User::events), + AwardInstance.UserValues.CODEC.listOf().fieldOf("awards").forGetter(User::awards), + Permission.STRING_PERMISSIONS_CODEC.fieldOf("permissions").forGetter(User::permissions), + Codec.dispatchedMap(Codec.STRING, INTEGRATION_CODECS::get).fieldOf("integrations").forGetter(User::integrations) + ).apply(inst, User::new)); + public static final Codec ID_CODEC = Codec.STRING.validate(User::validate); + + private static DataResult validate(String id) { + try (Connection connection = ModGardenBackend.createDatabaseConnection(); + PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM users WHERE id = ?")) { + prepared.setString(1, id); + ResultSet result = prepared.executeQuery(); + if (result != null && result.getBoolean(1)) + return DataResult.success(id); + } catch (SQLException ex) { + ModGardenBackend.LOG.error("Exception in SQL query.", ex); + } + return DataResult.error(() -> "Failed to get user with id '" + id + "'."); + } +} diff --git a/src/main/java/net/modgarden/backend/data/user/integration/DiscordIntegration.java b/src/main/java/net/modgarden/backend/data/user/integration/DiscordIntegration.java new file mode 100644 index 0000000..f8606a8 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/user/integration/DiscordIntegration.java @@ -0,0 +1,16 @@ +package net.modgarden.backend.data.user.integration; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.data.Integration; + +public record DiscordIntegration(String userId) implements Integration { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("user_id").forGetter(DiscordIntegration::userId) + ).apply(inst, DiscordIntegration::new)); + + @Override + public Codec getCodec() { + return CODEC; + } +} diff --git a/src/main/java/net/modgarden/backend/data/user/integration/MinecraftIntegration.java b/src/main/java/net/modgarden/backend/data/user/integration/MinecraftIntegration.java new file mode 100644 index 0000000..e206171 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/user/integration/MinecraftIntegration.java @@ -0,0 +1,18 @@ +package net.modgarden.backend.data.user.integration; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.data.Integration; + +import java.util.List; + +public record MinecraftIntegration(List accounts) implements Integration { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.list(Codec.STRING).fieldOf("accounts").forGetter(MinecraftIntegration::accounts) + ).apply(inst, MinecraftIntegration::new)); + + @Override + public Codec getCodec() { + return CODEC; + } +} diff --git a/src/main/java/net/modgarden/backend/data/user/integration/ModrinthIntegration.java b/src/main/java/net/modgarden/backend/data/user/integration/ModrinthIntegration.java new file mode 100644 index 0000000..c59ec0f --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/user/integration/ModrinthIntegration.java @@ -0,0 +1,16 @@ +package net.modgarden.backend.data.user.integration; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.modgarden.backend.data.Integration; + +public record ModrinthIntegration(String userId) implements Integration { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("user_id").forGetter(ModrinthIntegration::userId) + ).apply(inst, ModrinthIntegration::new)); + + @Override + public Codec getCodec() { + return CODEC; + } +} diff --git a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java new file mode 100644 index 0000000..c6e29f9 --- /dev/null +++ b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java @@ -0,0 +1,202 @@ +package net.modgarden.backend.database; + +import net.modgarden.backend.HypertextResult; +import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.Metadata; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.data.Platform; +import net.modgarden.backend.data.event.Project; +import net.modgarden.backend.data.event.Submission; +import net.modgarden.backend.data.event.metadata.DraftMetadata; +import net.modgarden.backend.data.event.metadata.ModMetadata; +import net.modgarden.backend.data.event.platform.ModrinthPlatform; +import net.modgarden.backend.endpoint.exception.NotFoundException; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class DatabaseAccess { + public Connection getDatabaseConnection() throws SQLException { + return ModGardenBackend.createDatabaseConnection(); + } + + public String getProjectIdFromSubmissionId( + @NotNull String submissionId + ) throws SQLException, NotFoundException { + Connection connection = this.getDatabaseConnection(); + try ( + var submissionIdStatement = connection.prepareStatement(""" + SELECT project_id + FROM submissions + WHERE id = ? + """); + ) { + submissionIdStatement.setString(1, submissionId); + ResultSet submissionResult = submissionIdStatement.executeQuery(); + if (!submissionResult.isBeforeFirst()) { + throw new NotFoundException("Could not find submission '" + submissionId + "'"); + } + + return submissionResult.getString("project_id"); + } + } + + public Submission getSubmissionFromId( + @NotNull String submissionId + ) throws Exception { + Connection connection = this.getDatabaseConnection(); + try ( + var submissionStatement = connection.prepareStatement(""" + SELECT event, project_id, submitted + FROM submissions + WHERE id = ? + """); + var modrinthSubmissionTypeStatement = connection.prepareStatement(""" + SELECT modrinth_id, version_id + FROM submission_type_modrinth + WHERE submission_id = ? + """) + ) { + submissionStatement.setString(1, submissionId); + ResultSet submissionResult = submissionStatement.executeQuery(); + if (!submissionResult.isBeforeFirst()) { + throw new NotFoundException("Could not find submission '" + submissionId + "'"); + } + + modrinthSubmissionTypeStatement.setString(1, submissionId); + ResultSet modrinthSubmissionTypeResult = modrinthSubmissionTypeStatement.executeQuery(); + + Platform platform; + // TODO: Implement download URL submission type. + if (modrinthSubmissionTypeResult.isBeforeFirst()) { + platform = new ModrinthPlatform( + modrinthSubmissionTypeResult.getString("modrinth_id"), + modrinthSubmissionTypeResult.getString("version_id") + ); + } else { + throw new RuntimeException("Submission does not have a valid 'platform'"); + } + + return new Submission( + submissionId, + submissionResult.getString("event"), + submissionResult.getLong("submitted"), + this.getProjectFromId(submissionResult.getString("project_id")), + platform + ); + } + } + + public Project getProjectFromId( + @NotNull String projectId + ) throws Exception { + Connection connection = this.getDatabaseConnection(); + Map team = new HashMap<>(); + Map permissions = new HashMap<>(); + List submissions = new ArrayList<>(); + try ( + var projectRolesStatement = connection.prepareStatement(""" + SELECT user_id, permissions, role_name + FROM project_roles + WHERE project_id = ? + """); + var projectDraftMetadataStatement = connection.prepareStatement(""" + SELECT name + FROM project_draft_metadata + WHERE project_id = ? + """); + var projectModMetadataStatement = connection.prepareStatement(""" + SELECT mod_id, name, description, source_url + FROM project_mod_metadata + WHERE project_id = ? + """); + var submissionsStatement = connection.prepareStatement(""" + SELECT id + FROM submissions + WHERE project_id = ? + """) + ) { + projectModMetadataStatement.setString(1, projectId); + ResultSet projectModMetadataResult = projectModMetadataStatement.executeQuery(); + + projectDraftMetadataStatement.setString(1, projectId); + ResultSet projectDraftMetadataResult = projectDraftMetadataStatement.executeQuery(); + + Metadata metadata; + if (projectModMetadataResult.isBeforeFirst()) { + metadata = new ModMetadata( + projectModMetadataResult.getString("mod_id"), + projectModMetadataResult.getString("name"), + projectModMetadataResult.getString("description"), + projectModMetadataResult.getString("source_url") + ); + } else if (projectDraftMetadataResult.isBeforeFirst()) { + metadata = new DraftMetadata( + projectDraftMetadataResult.getString("name") + ); + } else { + throw new NotFoundException("Could not find metadata for project '" + projectId + "'"); + } + + + projectRolesStatement.setString(1, projectId); + ResultSet projectRolesResult = projectRolesStatement.executeQuery(); + while (projectRolesResult.next()) { + String projectRoleUserId = projectRolesResult.getString("user_id"); + team.put(projectRoleUserId, projectRolesResult.getString("role_name")); + permissions.put(projectRoleUserId, projectRolesResult.getLong("permissions")); + } + + submissionsStatement.setString(1, projectId); + ResultSet submissionsResult = submissionsStatement.executeQuery(); + while (submissionsResult.next()) { + submissions.add(submissionsResult.getString("id")); + } + + return new Project( + projectId, + metadata, + team, + permissions, + submissions + ); + } + } + + public HypertextResult getUserPermissions(String userId) throws SQLException { + try ( + var connection = getDatabaseConnection(); + var userStatement = connection.prepareStatement("SELECT permissions FROM users WHERE id = ?") + ) { + userStatement.setString(1, userId); + ResultSet resultSet = userStatement.executeQuery(); + if (!resultSet.isBeforeFirst()) { + return new HypertextResult<>(404, "User does not exist."); + } + + return new HypertextResult<>(new Permissions(resultSet.getLong("permissions"))); + } + } + + public HypertextResult getProjectPermissions(String userId, String projectId) throws SQLException { + try ( + var connection = getDatabaseConnection(); + var userStatement = connection.prepareStatement("SELECT permissions FROM project_roles WHERE user_id = ? AND project_id = ?") + ) { + userStatement.setString(1, userId); + userStatement.setString(2, projectId); + ResultSet resultSet = userStatement.executeQuery(); + if (!resultSet.isBeforeFirst()) { + return new HypertextResult<>(404, "User does not have a role in the specified project."); + } + + return new HypertextResult<>(new Permissions(resultSet.getLong("permissions"))); + } + } +} diff --git a/src/main/java/net/modgarden/backend/database/DatabaseFunction.java b/src/main/java/net/modgarden/backend/database/DatabaseFunction.java new file mode 100644 index 0000000..aa69cfe --- /dev/null +++ b/src/main/java/net/modgarden/backend/database/DatabaseFunction.java @@ -0,0 +1,16 @@ +package net.modgarden.backend.database; + +import org.sqlite.Function; + +import java.sql.Connection; +import java.sql.SQLException; + +public abstract class DatabaseFunction extends Function { + protected DatabaseFunction() {} + + protected abstract String getName(); + + public void create(Connection connection) throws SQLException { + Function.create(connection, getName(), this); + } +} diff --git a/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java new file mode 100644 index 0000000..b0c9da1 --- /dev/null +++ b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java @@ -0,0 +1,26 @@ +package net.modgarden.backend.database.function; + +import net.modgarden.backend.data.NaturalId; +import net.modgarden.backend.database.DatabaseFunction; + +import java.sql.SQLException; + +public class GenerateNaturalIdFunction extends DatabaseFunction { + public static final GenerateNaturalIdFunction INSTANCE = new GenerateNaturalIdFunction(); + + protected GenerateNaturalIdFunction() {} + + @Override + protected void xFunc() throws SQLException { + String table = this.value_text(0); + String key = this.value_text(1); + String key2 = this.value_text(2); + int length = this.value_int(3); + this.result(NaturalId.generate(table, key, key2, length)); + } + + @Override + protected String getName() { + return "generate_natural_id"; + } +} diff --git a/src/main/java/net/modgarden/backend/database/function/HasPermissionsFunction.java b/src/main/java/net/modgarden/backend/database/function/HasPermissionsFunction.java new file mode 100644 index 0000000..12976cb --- /dev/null +++ b/src/main/java/net/modgarden/backend/database/function/HasPermissionsFunction.java @@ -0,0 +1,24 @@ +package net.modgarden.backend.database.function; + +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.database.DatabaseFunction; + +import java.sql.SQLException; + +public class HasPermissionsFunction extends DatabaseFunction { + public static final HasPermissionsFunction INSTANCE = new HasPermissionsFunction(); + + protected HasPermissionsFunction() {} + + @Override + protected void xFunc() throws SQLException { + Permissions permissions = new Permissions(this.value_long(0)); + Permissions requiredPermissions = new Permissions(this.value_long(1)); + this.result(permissions.hasPermissions(requiredPermissions) ? 1 : 0); + } + + @Override + protected String getName() { + return "has_permissions"; + } +} diff --git a/src/main/java/net/modgarden/backend/database/function/UnixMillisFunction.java b/src/main/java/net/modgarden/backend/database/function/UnixMillisFunction.java new file mode 100644 index 0000000..cd43d5d --- /dev/null +++ b/src/main/java/net/modgarden/backend/database/function/UnixMillisFunction.java @@ -0,0 +1,22 @@ +package net.modgarden.backend.database.function; + +import net.modgarden.backend.database.DatabaseFunction; + +import java.sql.SQLException; +import java.time.Instant; + +public class UnixMillisFunction extends DatabaseFunction { + public static final UnixMillisFunction INSTANCE = new UnixMillisFunction(); + + protected UnixMillisFunction() {} + + @Override + protected void xFunc() throws SQLException { + this.result(Instant.now().toEpochMilli()); + } + + @Override + protected String getName() { + return "unix_millis"; + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java new file mode 100644 index 0000000..6133b30 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -0,0 +1,289 @@ +package net.modgarden.backend.endpoint; + +import de.mkammerer.argon2.Argon2Advanced; +import de.mkammerer.argon2.Argon2Factory; +import io.javalin.http.Context; +import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.NaturalId; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.security.SecureRandom; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Arrays; +import java.util.stream.Collectors; + +public abstract class AuthorizedEndpoint extends Endpoint { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + /// OWASP [recommends](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) Argon2id. + private static final Argon2Advanced ARGON = + Argon2Factory.createAdvanced(Argon2Factory.Argon2Types.ARGON2id); + private final PermissionScope permissionScope; + private final boolean hasBody; + private final static String characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_+=[]{}|/?;:,.<>~"; + + public AuthorizedEndpoint(int version, String path, PermissionScope permissionScope, boolean hasBody) { + super(version, path); + this.permissionScope = permissionScope; + this.hasBody = hasBody; + } + + AuthorizedEndpoint(String path, PermissionScope permissionScope, boolean hasBody) { + super(path); + this.permissionScope = permissionScope; + this.hasBody = hasBody; + } + + public static String generateRandomToken() { + return generateSecretString(10); + } + + protected static String generateAPIKey() { + // we use 72 because it divides neatly with 3 (72/3 = 24) + return generateSecretString(72); + } + + /// Generate a secret (e.g. password) in [String] form. + protected static String generateSecretString(int length) { + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < length; i++) { + int randomInt = SECURE_RANDOM.nextInt(0, characters.length() - 1); + stringBuilder.append(characters.charAt(randomInt)); + } + return stringBuilder.toString(); + } + + /// Generate a salted hash for a secret (e.g. password). + protected static String hashSecret(String secret) { + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + return ARGON.hash( + 2, + 19 * 1024, + 1, + secret.toCharArray() + ); + } + + /// Verify that a secret (e.g. password) matches the given salt and hash. + protected static boolean verifySecret(String hash, String secret) { + return ARGON.verify(hash, secret.toCharArray()); + } + + protected abstract void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; + + @Override + public final void onRequest(@NotNull Context ctx) throws Exception { + ValidationResult validationResult = validateAuth(ctx); + if (!validationResult.authorized()) { + return; + } + + this.onRequest(ctx, validationResult.userId(), validationResult.scopePermissions()); + } + + protected @Nullable String getProjectId(Context ctx) throws SQLException { + return null; + } + + /// # Caution + /// Modifying this method is a dangerous game. + /// + /// If you choose to continue, know that a single logical error + /// can and likely will cause serious security vulnerabilities. + /// + /// Do not fuck with auth roulette. + /// Test your code before pushing to prod, please. + /// + /// **You have been warned.** + /// + /// ## Past Incidents + /// Security incidents related to this method are detailed below. + /// If an incident is not documented, create a sub-heading with + /// the date, the severity (Minimal, Moderate, Severe), known usage + /// (None, Rare, Common, Unknown), and an ominous title. + /// + /// ### `2025-10-06` (Minimal/None) No Incidents! + /// Yay. + /// ### `2025-10-08` (Moderate/None) Scope Leak + /// Prior to commit `bb823cd`, API keys were not correctly scoped. + /// Instead, API keys' permissions were restricted only to a + /// users' permissions. This is dangerous because administrators' + /// and project owners' API keys could access anything they could + /// access which violates the [Principle of Least Privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege). + private ValidationResult validateAuth(Context ctx) throws SQLException { + String authorization = ctx.header("Authorization"); + if (authorization == null) { + ctx.result("Unauthorized."); + ctx.status(401); + return ValidationResult.no(); + } + + boolean authorized = ("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals( + authorization); + Permissions scopePermissions = new Permissions(); + // we know this is GardenBot. let it bypass everything + if (authorized) { + scopePermissions = new Permissions(Permission.ADMINISTRATOR); + return new ValidationResult(true, "grbot", scopePermissions); + } + + String projectId = this.getProjectId(ctx); + + String idSecretPair = authorization.split(" ")[1]; + String[] idSecretPairSplit = idSecretPair.split(":"); + String userId = idSecretPairSplit[0]; + String secret = null; + if (idSecretPairSplit.length > 1) { + secret = Arrays.stream(idSecretPairSplit) + .skip(1) + .collect(Collectors.joining(":")); + } + + if (secret != null) { + try ( + var connection = this.getDatabaseConnection(); + var apiKeyStatement = + connection.prepareStatement("SELECT hash, uuid, expires FROM api_keys WHERE user_id = ?"); + var apiKeyScopeStatement = + connection.prepareStatement("SELECT scope, project_id, permissions FROM api_key_scopes WHERE uuid = ?") + ) { + apiKeyStatement.setString(1, userId); + ResultSet apiKeyResult = apiKeyStatement.executeQuery(); + if (!apiKeyResult.isBeforeFirst()) { + this.setStatusUnauthorized(ctx); + return ValidationResult.no(); + } + + while (!authorized && apiKeyResult.next()) { + byte[] uuid = apiKeyResult.getBytes("uuid"); + apiKeyScopeStatement.setBytes(1, uuid); + ResultSet apiKeyScopeResult = apiKeyScopeStatement.executeQuery(); + if (!apiKeyScopeResult.isBeforeFirst()) { + this.setStatusUnauthorized(ctx); + return ValidationResult.no(); + } + + // forbid expired keys + if (Instant.now().isAfter(Instant.ofEpochMilli(apiKeyResult.getLong("expires")))) { + this.setStatusUnauthorized(ctx); + + // remove expired key + try ( + var apiKeyExpiredStatement = + connection.prepareStatement("DELETE FROM api_keys WHERE uuid = ?") + ) { + apiKeyExpiredStatement.setBytes(1, uuid); + apiKeyExpiredStatement.execute(); + } + + return ValidationResult.no(); + } + + // validate permission scope matches + PermissionScope scope = PermissionScope.fromString(apiKeyScopeResult.getString("scope")); + if (scope != this.permissionScope && this.permissionScope != PermissionScope.ALL) { + ctx.result("Permission scope " + scope + " does not match the scope " + this.permissionScope + " for this endpoint ."); + ctx.status(403); + return ValidationResult.no(); + } + + // validate project ID matches + if (!(this instanceof GenerateKeyEndpoint) && projectId != null && !projectId.equals(apiKeyScopeResult.getString("project_id"))) { + ctx.result("Project ID " + projectId + " does not match the project ID for this scope."); + ctx.status(403); + return ValidationResult.no(); + } + + String hash = apiKeyResult.getString("hash"); + authorized = verifySecret(hash, secret); + + // give this endpoint the permissions as specified by the API key + if (authorized) { + Permissions apiKeyPermissions = new Permissions(apiKeyScopeResult.getLong("permissions")); + // Disallow permissions the user doesn't already have + switch (scope) { + case USER -> { + Permissions userPermissions = this.getDatabaseAccess() + .getUserPermissions(userId) + .unwrap(ctx); + if (userPermissions == null) { + this.setStatusUnauthorized(ctx); + return ValidationResult.no(); + } + scopePermissions = userPermissions; + scopePermissions = scopePermissions.restrict(apiKeyPermissions.bits()); + } + case PROJECT -> { + Permissions projectPermissions = this.getDatabaseAccess() + .getProjectPermissions(userId, projectId) + .unwrap(ctx); + if (projectPermissions == null) { + this.setStatusUnauthorized(ctx); + return ValidationResult.no(); + } + scopePermissions = projectPermissions; + scopePermissions = scopePermissions.restrict(apiKeyPermissions.bits()); + } + } + } + } + } + } + if (!authorized && !ctx.status().isError()) { + this.setStatusUnauthorized(ctx); + return ValidationResult.no(); + } + + return new ValidationResult(authorized, userId, scopePermissions); + } + + protected void setStatusUnauthorized(Context ctx) { + ctx.result("Unauthorized."); + ctx.status(401); + } + + protected void setStatusForbidden(Context ctx) { + ctx.result("Forbidden."); + ctx.status(403); + } + + private boolean requireAllPermissions(Context ctx, Permissions scopePermissions, Permissions permissions) { + if (!scopePermissions.hasPermissions(permissions)) { + ctx.status(403); + ctx.result("User lacks permission; required " + permissions); + return true; + } + + return false; + } + + private boolean requireAnyPermissions(Context ctx, Permissions scopePermissions, Permissions permissions) { + if (!scopePermissions.hasAnyPermissions(permissions)) { + ctx.status(403); + ctx.result("User lacks permission; required any of " + permissions); + return false; + } + + return true; + } + + protected boolean requireAllPermissions(Context ctx, Permissions scopePermissions, Permission... permissions) { + return requireAllPermissions(ctx, scopePermissions, new Permissions(permissions)); + } + + protected boolean requireAnyPermissions(Context ctx, Permissions scopePermissions, Permission... permissions) { + return requireAnyPermissions(ctx, scopePermissions, new Permissions(permissions)); + } + + private record ValidationResult(boolean authorized, String userId, Permissions scopePermissions) { + public static ValidationResult no() { + return new ValidationResult(false, NaturalId.getMissingno(), new Permissions()); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java new file mode 100644 index 0000000..d034ee1 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java @@ -0,0 +1,94 @@ +package net.modgarden.backend.endpoint; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import net.modgarden.backend.HypertextResult; +import net.modgarden.backend.database.DatabaseAccess; +import net.modgarden.backend.endpoint.exception.NotFoundException; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.SQLException; + +// witnesses would be *real* nice here. *sigh* +@EndpointPath("/v2") +public abstract class Endpoint implements Handler { + public static final String SAFE_URL_REGEX = "[a-zA-Z0-9!@$()`.+,_\"-]+"; + + private final String path; + private final DatabaseAccess databaseAccess = new DatabaseAccess(); + + public Endpoint(int version, String path) { + this.path = "/v" + version + "/" + path; + } + + // for our other types of Endpoints that don't follow the /vN/ convention + Endpoint(String path) { + this.path = path; + } + + @Override + public final void handle(@NotNull Context ctx) throws Exception { + // validate all path params + for (String pathParam : ctx.pathParamMap().values()) { + if (!pathParam.matches(SAFE_URL_REGEX)) { + ctx.result("Illegal characters in path '" + pathParam + "'."); + ctx.status(422); + return; + } + } + + try { + this.onRequest(ctx); + } catch (NotFoundException npe) { + ctx.status(404); + ctx.result(npe.getMessage()); + } + } + + public abstract void onRequest(@NotNull Context ctx) throws Exception; + + public String getPath() { + return path; + } + + protected DatabaseAccess getDatabaseAccess() { + return databaseAccess; + } + + protected Connection getDatabaseConnection() throws SQLException { + return this.getDatabaseAccess().getDatabaseConnection(); + } + + protected void invalidBody(Context ctx, String message) { + ctx.status(400); + ctx.result("Invalid body: " + message); + } + + protected HypertextResult decodeBody(Context ctx, Codec codec) { + DataResult> result = codec.decode( + JsonOps.INSTANCE, JsonParser.parseString(ctx.body())); + + if (result.isError()) { + //noinspection OptionalGetWithoutIsPresent + this.invalidBody(ctx, result.error().get().message()); + return new HypertextResult<>(ctx.statusCode(), ctx.result()); + } + + T bodyResult; + try { + bodyResult = result.getOrThrow().getFirst(); + } catch (IllegalStateException e) { + this.invalidBody(ctx, e.getMessage()); + return new HypertextResult<>(ctx.statusCode(), ctx.result()); + } + + return new HypertextResult<>(bodyResult); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/EndpointMethod.java b/src/main/java/net/modgarden/backend/endpoint/EndpointMethod.java new file mode 100644 index 0000000..80c20b7 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/EndpointMethod.java @@ -0,0 +1,17 @@ +package net.modgarden.backend.endpoint; + +import java.lang.annotation.*; + +@Documented +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface EndpointMethod { + Method value(); + + enum Method { + GET, + POST, + PUT, + DELETE, + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/EndpointPath.java b/src/main/java/net/modgarden/backend/endpoint/EndpointPath.java new file mode 100644 index 0000000..e25f786 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/EndpointPath.java @@ -0,0 +1,12 @@ +package net.modgarden.backend.endpoint; + +import java.lang.annotation.*; + +/// An annotation that quickly documents what path an +/// endpoint resides at. +@Documented +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface EndpointPath { + String value(); +} diff --git a/src/main/java/net/modgarden/backend/endpoint/InternalEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/InternalEndpoint.java new file mode 100644 index 0000000..9700a20 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/InternalEndpoint.java @@ -0,0 +1,21 @@ +package net.modgarden.backend.endpoint; + +import net.modgarden.backend.data.PermissionScope; + +/** + * A kind of {@link Endpoint} that is used only internally. + *

+ * Beware! These endpoints are for internal team use only! + * Discussion of these endpoints in public spaces is heavily frowned upon. + * Usage of these endpoints is also discouraged, as your project will break. + * These endpoints may change at any time without notice. + */ +@EndpointPath("/internal") +public abstract class InternalEndpoint extends AuthorizedEndpoint { + public InternalEndpoint( + String path, + boolean hasBody + ) { + super("/internal/" + path, PermissionScope.USER, hasBody); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/exception/NotFoundException.java b/src/main/java/net/modgarden/backend/endpoint/exception/NotFoundException.java new file mode 100644 index 0000000..ca1ab61 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package net.modgarden.backend.endpoint.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java new file mode 100644 index 0000000..4d65132 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java @@ -0,0 +1,18 @@ +package net.modgarden.backend.endpoint.v2; + +import io.javalin.http.Context; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.AuthorizedEndpoint; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; + +@EndpointPath("/v2/auth") +public abstract class AuthEndpoint extends AuthorizedEndpoint { + public AuthEndpoint(String path, PermissionScope permissionScope, boolean hasBody) { + super(2, "auth/" + path, permissionScope, hasBody); + } + + @Override + public abstract void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java new file mode 100644 index 0000000..5d39354 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java @@ -0,0 +1,60 @@ +package net.modgarden.backend.endpoint.v2; + +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.AuthorizedEndpoint; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.ResultSet; + +@EndpointPath("/v2/project") +public abstract class AuthorizedProjectEndpoint extends AuthorizedEndpoint { + public AuthorizedProjectEndpoint(String path, boolean hasBody) { + this(path, PermissionScope.PROJECT, hasBody); + } + + protected AuthorizedProjectEndpoint(String path, PermissionScope scope, boolean hasBody) { + super(2, "project/" + path, scope, hasBody); + } + + @Override + public abstract void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; + + @NotNull + @Override + protected abstract String getProjectId(Context ctx); + + protected static boolean requireUserCanModifyMember( + Context ctx, + Connection connection, + String projectId, + String memberUserIdToModify, + Permissions selfPermissions + ) throws Exception { + try ( + var memberPermissionsStatement = connection.prepareStatement(""" + SELECT permissions + FROM project_roles + WHERE project_id = ? AND user_id = ? + """) + ) { + memberPermissionsStatement.setString(1, projectId); + memberPermissionsStatement.setString(2, memberUserIdToModify); + ResultSet memberPermissionsResult = memberPermissionsStatement.executeQuery(); + Permissions memberPermissions = new Permissions(memberPermissionsResult.getLong(1)); + + // If a non-administrator attempts to edit the permissions of an administrator, return false. + if (memberPermissions.hasPermissions(Permission.ADMINISTRATOR) && !selfPermissions.hasPermissions(Permission.ADMINISTRATOR)) { + ctx.status(403); + ctx.result("Non-administrators may not edit administrators' permissions on projects"); + return true; + } + } + + return false; + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java new file mode 100644 index 0000000..cf3a0ee --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java @@ -0,0 +1,25 @@ +package net.modgarden.backend.endpoint.v2; + +import io.javalin.http.Context; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.AuthorizedEndpoint; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.sql.SQLException; + +@EndpointPath("/v2/submission") +public abstract class AuthorizedSubmissionEndpoint extends AuthorizedEndpoint { + public AuthorizedSubmissionEndpoint(String path, PermissionScope permissionScope, boolean hasBody) { + super(2, "submission/" + path, permissionScope, hasBody); + } + + @NotNull + @Override + protected abstract String getProjectId(Context ctx) throws SQLException; + + @Override + public abstract void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java new file mode 100644 index 0000000..613e22e --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java @@ -0,0 +1,60 @@ +package net.modgarden.backend.endpoint.v2.auth; + +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthEndpoint; +import net.modgarden.backend.util.UuidUtils; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; +import java.util.UUID; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; + +@EndpointMethod(DELETE) +@EndpointPath("/v2/auth/api_key/{uuid}") +public final class DeleteKeyEndpoint extends AuthEndpoint { + public DeleteKeyEndpoint() { + super("api_key/{uuid}", PermissionScope.ALL, false); + } + + @Override + public void onRequest( + @NotNull Context ctx, + String userId, + Permissions scopePermissions + ) throws Exception { + if (this.requireAllPermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; + + UUID uuid = UUID.fromString(ctx.pathParam("uuid")); + + try ( + var connection = this.getDatabaseConnection(); + var apiKeyScopeStatement = connection.prepareStatement("SELECT scope, project_id FROM api_key_scopes WHERE uuid = ?"); + var deleteApiKeyStatement = connection.prepareStatement("DELETE FROM api_keys WHERE uuid = ?") + ) { + apiKeyScopeStatement.setBytes(1, UuidUtils.toBytes(uuid)); + ResultSet apiKeyScopeResult = apiKeyScopeStatement.executeQuery(); + if (!apiKeyScopeResult.isBeforeFirst()) { + return; + } + + String projectId = apiKeyScopeResult.getString("project_id"); + PermissionScope permissionScope = PermissionScope.fromString(apiKeyScopeResult.getString("scope")); + + if (permissionScope == PermissionScope.PROJECT) { + Permissions permissions = this.getDatabaseAccess() + .getProjectPermissions(userId, projectId) + .unwrap(ctx); + if (this.requireAllPermissions(ctx, permissions, Permission.MODIFY_API_KEY)) return; + } + + deleteApiKeyStatement.setBytes(1, UuidUtils.toBytes(uuid)); + deleteApiKeyStatement.execute(); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java new file mode 100644 index 0000000..6ec48a6 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -0,0 +1,203 @@ +package net.modgarden.backend.endpoint.v2.auth; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthEndpoint; +import net.modgarden.backend.util.ExtraCodecs; +import net.modgarden.backend.util.UuidUtils; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static java.util.Map.entry; +import static net.modgarden.backend.endpoint.EndpointMethod.Method.POST; + +@EndpointMethod(POST) +@EndpointPath("/v2/auth/api_key") +public final class GenerateKeyEndpoint extends AuthEndpoint { + public GenerateKeyEndpoint() { + super("api_key", PermissionScope.ALL, true); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + if (this.requireAllPermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; + + Request request = this.decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + if (request == null) return; + + if (Duration.between(Instant.now(), request.expires()).toDays() > 365 || Duration.between(Instant.now(), request.expires()).isNegative()) { + ctx.status(400); + ctx.result("API key expires too late or too early. It must expire at most in a year."); + return; + } + + byte[] uuid = UuidUtils.randomBytes(); + String apiKey = AuthEndpoint.generateAPIKey(); + String hash = + AuthEndpoint.hashSecret(apiKey); + + Permissions requestedPermissions = request.permissions(); + String projectId = null; + if (request.projectId().isPresent()) { + projectId = request.projectId().get(); + } + + if (projectId != null) { + try ( + var connection = this.getDatabaseConnection(); + var projectStatement = connection.prepareStatement("SELECT id FROM projects WHERE id = ?") + ) { + projectStatement.setString(1, projectId); + ResultSet resultSet = projectStatement.executeQuery(); + if (!resultSet.isBeforeFirst()) { + ctx.status(404); + ctx.result("Project with ID " + projectId + " does not exist"); + return; + } + } + } + + switch (request.scope().id()) { + case "project" -> { + try ( + var connection = this.getDatabaseConnection(); + var permissionStatement = connection.prepareStatement("SELECT permissions FROM project_roles WHERE user_id = ? AND project_id = ?") + ) { + permissionStatement.setString(1, userId); + permissionStatement.setString(2, projectId); + ResultSet resultSet = permissionStatement.executeQuery(); + Permissions projectPermissions = new Permissions(resultSet.getLong("permissions")); + requestedPermissions = requestedPermissions.restrict(projectPermissions.bits()); + if (this.requireAllPermissions(ctx, projectPermissions, Permission.MODIFY_API_KEY)) return; + } + } + case "user" -> requestedPermissions = requestedPermissions.restrict( + Permission.DEFAULT_USER_PERMISSIONS.bits() | scopePermissions.bits()); + } + + try ( + var connection = this.getDatabaseConnection(); + var apiKeyStatement = connection.prepareStatement("INSERT INTO api_keys(uuid, user_id, hash, expires, name) VALUES (?, ?, ?, ?, ?)") + ) { + apiKeyStatement.setBytes(1, uuid); + apiKeyStatement.setString(2, userId); + apiKeyStatement.setString(3, hash); + apiKeyStatement.setLong(4, request.expires().toEpochMilli()); + apiKeyStatement.setString(5, request.name()); + apiKeyStatement.execute(); + } + + try ( + var connection = this.getDatabaseConnection(); + var apiKeyScopeStatement = connection.prepareStatement("INSERT INTO api_key_scopes(uuid, scope, project_id, permissions) VALUES (?, ?, ?, ?)") + ) { + apiKeyScopeStatement.setBytes(1, uuid); + apiKeyScopeStatement.setString(2, request.scope().id()); + if (projectId != null) { + apiKeyScopeStatement.setString(3, projectId); + } else { + // actually what the hell lmao. what is this second integer? + apiKeyScopeStatement.setNull(3, 0); + } + apiKeyScopeStatement.setLong(4, requestedPermissions.bits()); + apiKeyScopeStatement.execute(); + } + + ctx.json(new Response(apiKey, UuidUtils.fromBytes(uuid))); + ctx.status(200); + } + + private interface Scope { + Codec CODEC = Codec.STRING.dispatch("scope", scope -> scope.getType().id(), string -> ScopeType.SCOPE_TYPES.get(string).getCodec()); + + ScopeType getType(); + } + + public record Request( + ScopeType scope, + Optional projectId, + Permissions permissions, + Instant expires, + String name + ) { + public static final Codec> CODEC = Scope.CODEC.xmap( + scope -> { + if (scope instanceof ProjectScope(String id, Permissions permissions, Instant expires, String name)) { + return new Request<>(ProjectScope.TYPE, Optional.of(id), permissions, expires, name); + } else if (scope instanceof UserScope(Permissions permissions, Instant expires, String name)) { + return new Request<>(UserScope.TYPE, Optional.empty(), permissions, expires, name); + } else { + throw new IllegalStateException("Unregistered scope type. Please do not let this ever happen."); + } + }, + request -> { + if (request.projectId().isPresent()) { + return new ProjectScope(request.projectId().get(), request.permissions(), request.expires(), request.name()); + } else { + return new UserScope(request.permissions(), request.expires(), request.name()); + } + } + ); + } + + public record Response(String apiKey, UUID uuid) { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("api_key").forGetter(Response::apiKey), + ExtraCodecs.UUID_CODEC.fieldOf("uuid").forGetter(Response::uuid) + ).apply(inst, Response::new)); + } + + private record ScopeType(String id, MapCodec codec) { + public static final Map> SCOPE_TYPES = Map.ofEntries( + entry("project", ProjectScope.TYPE), + entry("user", UserScope.TYPE) + ); + + public MapCodec getCodec() { + return codec; + } + } + + private record ProjectScope(String projectId, Permissions permissions, Instant expires, String name) implements Scope { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( + Codec.STRING.fieldOf("project_id").forGetter(ProjectScope::projectId), + Permission.STRING_PERMISSIONS_CODEC.fieldOf("permissions").forGetter(ProjectScope::permissions), + ExtraCodecs.INSTANT_CODEC.fieldOf("expires").forGetter(ProjectScope::expires), + Codec.STRING.fieldOf("name").forGetter(ProjectScope::name) + ).apply(inst, ProjectScope::new)); + public static final ScopeType TYPE = new ScopeType<>("project", ProjectScope.CODEC); + + @Override + public ScopeType getType() { + return TYPE; + } + } + + private record UserScope(Permissions permissions, Instant expires, String name) implements Scope { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( + Permission.STRING_PERMISSIONS_CODEC.fieldOf("permissions").forGetter(UserScope::permissions), + ExtraCodecs.INSTANT_CODEC.fieldOf("expires").forGetter(UserScope::expires), + Codec.STRING.fieldOf("name").forGetter(UserScope::name) + ).apply(inst, UserScope::new)); + public static final ScopeType TYPE = new ScopeType<>("user", UserScope.CODEC); + + @Override + public ScopeType getType() { + return TYPE; + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java new file mode 100644 index 0000000..67cc2a5 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java @@ -0,0 +1,103 @@ +package net.modgarden.backend.endpoint.v2.auth; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthEndpoint; +import net.modgarden.backend.util.ExtraCodecs; +import net.modgarden.backend.util.UuidUtils; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.GET; + +@EndpointMethod(GET) +@EndpointPath("/v2/auth/api_key") +public final class ListKeysEndpoint extends AuthEndpoint { + public ListKeysEndpoint() { + super("api_key", PermissionScope.ALL, false); + } + + @Override + public void onRequest( + @NotNull Context ctx, + String userId, + Permissions scopePermissions + ) throws Exception { + String projectId = ctx.queryParam("project_id"); + String query; + if (projectId == null) { + query = "SELECT scope, project_id, permissions FROM api_key_scopes WHERE uuid = ?"; + } else { + query = "SELECT scope, project_id, permissions FROM api_key_scopes WHERE uuid = ? AND project_id = ?"; + } + + List apiKeys = new ArrayList<>(); + try ( + var connection = this.getDatabaseConnection(); + var apiKeyStatement = connection.prepareStatement("SELECT uuid, expires, name FROM api_keys WHERE user_id = ?"); + var apiKeyScopeStatement = connection.prepareStatement(query) + ) { + apiKeyStatement.setString(1, userId); + ResultSet resultSet = apiKeyStatement.executeQuery(); + while (resultSet.next()) { + UUID uuid = UuidUtils.fromBytes(resultSet.getBytes("uuid")); + apiKeyScopeStatement.setBytes(1, resultSet.getBytes("uuid")); + if (projectId != null) { + apiKeyScopeStatement.setString(2, projectId); + } + ResultSet scopeResult = apiKeyScopeStatement.executeQuery(); + if (!scopeResult.isBeforeFirst()) { + continue; + } + + apiKeys.add(new ApiKey( + uuid, + new Permissions(scopeResult.getLong("permissions")), + Instant.ofEpochMilli(resultSet.getLong("expires")), + PermissionScope.fromString(scopeResult.getString("scope")), + Optional.ofNullable(scopeResult.getString("project_id")), + resultSet.getString("name") + )); + } + } + + ctx.json(new Response(apiKeys)); + ctx.status(200); + } + + public record Response(List apiKeys) { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.list(ApiKey.CODEC).fieldOf("api_keys").forGetter(Response::apiKeys) + ).apply(inst, Response::new)); + } + + public record ApiKey( + UUID uuid, + Permissions permissions, + Instant expires, + PermissionScope scope, + Optional projectId, + String name + ) { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + ExtraCodecs.UUID_CODEC.fieldOf("uuid").forGetter(ApiKey::uuid), + Permission.STRING_PERMISSIONS_CODEC.fieldOf("permissions").forGetter(ApiKey::permissions), + ExtraCodecs.INSTANT_CODEC.fieldOf("expires").forGetter(ApiKey::expires), + PermissionScope.CODEC.fieldOf("scope").forGetter(ApiKey::scope), + Codec.STRING.optionalFieldOf("project_id").forGetter(ApiKey::projectId), + Codec.STRING.fieldOf("name").forGetter(ApiKey::name) + ).apply(inst, ApiKey::new)); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java new file mode 100644 index 0000000..7b42061 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java @@ -0,0 +1,86 @@ +package net.modgarden.backend.endpoint.v2.event; + +import io.javalin.http.Context; +import net.modgarden.backend.data.event.Submission; +import net.modgarden.backend.database.DatabaseAccess; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.submission.GetSubmissionEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.GET; + +@EndpointMethod(GET) +@EndpointPath("/v2/event/{event_type_slug}/{event_slug}/mod_id/{mod_id}") +public class GetSubmissionByModIdEndpoint extends GetSubmissionEndpoint { + public GetSubmissionByModIdEndpoint() { + super("event/{event_type_slug}/{event_slug}/mod_id/{mod_id}"); + } + + @SuppressWarnings("DuplicatedCode") + @Override + public void onRequest(@NotNull Context ctx) throws Exception { + String eventTypeSlug = ctx.pathParam("event_type_slug").toLowerCase(Locale.ROOT); + String eventSlug = ctx.pathParam("event_slug").toLowerCase(Locale.ROOT); + String modId = ctx.pathParam("mod_id").toLowerCase(Locale.ROOT); + + try ( + var connection = this.getDatabaseConnection(); + var eventStatement = connection.prepareStatement(""" + SELECT id + FROM events + WHERE event_type_slug = ? AND slug = ? + """); + var projectModMetadataStatement = connection.prepareStatement(""" + SELECT project_id + FROM project_mod_metadata + WHERE mod_id = ? + """); + var submissionsStatement = connection.prepareStatement(""" + SELECT id + FROM submissions + WHERE project_id = ? AND event = ? + """) + ) { + eventStatement.setString(1, eventTypeSlug); + eventStatement.setString(2, eventSlug); + var eventResult = eventStatement.executeQuery(); + + if (!eventResult.isBeforeFirst()) { + ctx.result("Could not find event '" + eventSlug + "' for event type '" + eventTypeSlug + "'"); + ctx.status(404); + return; + } + + projectModMetadataStatement.setString(1, modId); + var projectMetadataResult = projectModMetadataStatement.executeQuery(); + + if (!projectMetadataResult.isBeforeFirst()) { + ctx.result("Could not find mod with id '" + modId + "'"); + ctx.status(404); + return; + } + + String projectId = projectMetadataResult.getString("project_id"); + String event = eventResult.getString("id"); + + submissionsStatement.setString(1, projectId); + submissionsStatement.setString(2, event); + var submissionsResult = submissionsStatement.executeQuery(); + + String submissionId = submissionsResult.getString("id"); + + if (submissionId == null) { + ctx.result("Could not find submission for mod with ID '" + modId + "' for event '" + eventSlug + "' for event type '" + eventTypeSlug + "'"); + ctx.status(404); + return; + } + + Submission submission = this.getDatabaseAccess().getSubmissionFromId(submissionId); + ctx.json(submission); + ctx.status(200); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java new file mode 100644 index 0000000..0c9da17 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java @@ -0,0 +1,72 @@ +package net.modgarden.backend.endpoint.v2.project; + +import com.mojang.serialization.Codec; +import io.javalin.http.Context; +import net.modgarden.backend.data.NaturalId; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.POST; + +@EndpointMethod(POST) +@EndpointPath("/v2/project/create") +public class CreateProjectEndpoint extends AuthorizedProjectEndpoint { + public CreateProjectEndpoint() { + super("create", PermissionScope.USER, true); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + String generatedProjectId = NaturalId.generate("projects", "id", null, 5); + Request request = decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + + if (request == null) return; + + try ( + var connection = this.getDatabaseConnection(); + var projectStatement = connection.prepareStatement(""" + INSERT INTO projects (id) + VALUES (?) + """); + var projectDraftMetadataStatement = connection.prepareStatement(""" + INSERT INTO project_draft_metadata (project_id, name) + VALUES (?, ?) + """); + var projectRolesStatement = connection.prepareStatement(""" + INSERT OR IGNORE INTO project_roles (project_id, user_id, permissions) + VALUES (?, ?, 1) + """) + ) { + projectStatement.setString(1, generatedProjectId); + projectStatement.executeUpdate(); + + projectDraftMetadataStatement.setString(1, generatedProjectId); + projectDraftMetadataStatement.setString(2, request.name()); + projectDraftMetadataStatement.executeUpdate(); + + projectRolesStatement.setString(1, generatedProjectId); + projectRolesStatement.setString(2, userId); + projectRolesStatement.executeUpdate(); + + ctx.status(201); + ctx.header("Location", "/v2/project/" + generatedProjectId); + } + } + + @NotNull + @SuppressWarnings("DataFlowIssue") // we don't care since this endpoint doesn't require project perms + @Override + protected String getProjectId(Context ctx) { + return null; + } + + public record Request(String name) { + public static final Codec CODEC = Codec.STRING.xmap(Request::new, Request::name); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java new file mode 100644 index 0000000..d0f3276 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -0,0 +1,45 @@ +package net.modgarden.backend.endpoint.v2.project; + +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; + +@EndpointMethod(DELETE) +@EndpointPath("/v2/project/{project_id}") +public class DeleteProjectEndpoint extends AuthorizedProjectEndpoint { + public DeleteProjectEndpoint() { + super("{project_id}", false); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; + + String projectId = this.getProjectId(ctx); + + try ( + var connection = this.getDatabaseConnection(); + var statement = connection.prepareStatement(""" + DELETE FROM projects + WHERE id = ? + """) + ) { + statement.setString(1, projectId); + statement.executeUpdate(); + } + } + + @NotNull + @Override + protected String getProjectId(Context ctx) { + return ctx.pathParam("project_id"); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java new file mode 100644 index 0000000..12e91d8 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java @@ -0,0 +1,44 @@ +package net.modgarden.backend.endpoint.v2.project; + +import io.javalin.http.Context; +import net.modgarden.backend.data.event.Project; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.GET; + +@EndpointMethod(GET) +@EndpointPath("/v2/project/{project_id}") +public class GetProjectByIdEndpoint extends GetProjectEndpoint { + public GetProjectByIdEndpoint() { + super("{project_id}"); + } + + @Override + public void onRequest(@NotNull Context ctx) throws Exception { + String projectId = ctx.pathParam("project_id"); + try ( + var connection = this.getDatabaseConnection(); + var projectStatement = connection.prepareStatement(""" + SELECT 1 + FROM projects + WHERE id = ? + """) + ) { + projectStatement.setString(1, projectId); + ResultSet projectResult = projectStatement.executeQuery(); + if (!projectResult.isBeforeFirst()) { + ctx.result("Could not find project from id '" + projectId + "'"); + ctx.status(404); + return; + } + + Project project = this.getDatabaseAccess().getProjectFromId(projectId); + ctx.json(project); + ctx.status(200); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java new file mode 100644 index 0000000..5a0f5c9 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java @@ -0,0 +1,48 @@ +package net.modgarden.backend.endpoint.v2.project; + +import io.javalin.http.Context; +import net.modgarden.backend.data.event.Project; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.GET; + +@EndpointMethod(GET) +@EndpointPath("/v2/project/mod_id/{mod_id}") +public class GetProjectByModIdEndpoint extends GetProjectEndpoint { + public GetProjectByModIdEndpoint() { + super("mod_id/{mod_id}"); + } + + @Override + public void onRequest(@NotNull Context ctx) throws Exception { + String modId = ctx.pathParam("mod_id"); + + try ( + var connection = this.getDatabaseConnection(); + var projectMetadataStatement = connection.prepareStatement(""" + SELECT project_id + FROM project_mod_metadata + WHERE mod_id = ? + """) + ) { + projectMetadataStatement.setString(1, modId); + ResultSet projectResult = projectMetadataStatement.executeQuery(); + String projectId = projectResult.getString("project_id"); + + if (projectId == null) { + ctx.result("Could not find project from mod id '" + modId + "'"); + ctx.status(404); + return; + } + + Project project = this.getDatabaseAccess().getProjectFromId(projectId); + + ctx.json(project); + ctx.status(200); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java new file mode 100644 index 0000000..b58b8d9 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -0,0 +1,17 @@ +package net.modgarden.backend.endpoint.v2.project; + +import io.javalin.http.Context; +import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; + +// TODO: Require view project permissions or being a member of the project to view draft projects. +@EndpointPath("/v2/project") +public abstract class GetProjectEndpoint extends Endpoint { + public GetProjectEndpoint(String path) { + super(2, "project/" + path); + } + + @Override + public abstract void onRequest(@NotNull Context ctx) throws Exception; +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/member/AddMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/AddMemberEndpoint.java new file mode 100644 index 0000000..9dd72f2 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/AddMemberEndpoint.java @@ -0,0 +1,57 @@ +package net.modgarden.backend.endpoint.v2.project.member; + +import com.mojang.serialization.Codec; +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.data.user.User; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.PUT; + +@EndpointMethod(PUT) +@EndpointPath("/v2/project/{project_id}/add_member") +public class AddMemberEndpoint extends AuthorizedProjectEndpoint { + public AddMemberEndpoint() { + super("{project_id}/add_member", true); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; + + String projectId = this.getProjectId(ctx); + Request request = decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + + if (request == null) return; + + try ( + var connection = this.getDatabaseConnection(); + var insertStatement = connection.prepareStatement(""" + INSERT OR IGNORE INTO project_roles (project_id, user_id) + VALUES (?, ?) + """) + ) { + insertStatement.setString(1, projectId); + insertStatement.setString(2, request.userId()); + insertStatement.executeUpdate(); + } + } + + @NotNull + @Override + protected String getProjectId(Context ctx) { + return ctx.pathParam("project_id"); + } + + public record Request(String userId) { + public static final Codec CODEC = User.ID_CODEC.xmap(Request::new, Request::userId); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/member/RemoveMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/RemoveMemberEndpoint.java new file mode 100644 index 0000000..7b410bb --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/RemoveMemberEndpoint.java @@ -0,0 +1,89 @@ +package net.modgarden.backend.endpoint.v2.project.member; + +import com.mojang.serialization.Codec; +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.data.user.User; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; + +@EndpointMethod(DELETE) +@EndpointPath("/v2/project/{project_id}/remove_member") +public class RemoveMemberEndpoint extends AuthorizedProjectEndpoint { + public RemoveMemberEndpoint() { + super("{project_id}/remove_member", true); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + + String projectId = this.getProjectId(ctx); + Request request = decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + + if (request == null || !request.userId().equals(userId) && this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; + + try ( + var connection = this.getDatabaseConnection(); + var memberPermissionsStatement = connection.prepareStatement(""" + SELECT permissions + FROM project_roles + WHERE project_id = ? AND user_id = ? + """); + var permissionCountStatement = connection.prepareStatement(""" + SELECT COUNT(*) + FROM project_roles + WHERE project_id = ? AND has_permissions(permissions, 1) + """); + var deleteStatement = connection.prepareStatement(""" + DELETE FROM project_roles + WHERE project_id = ? AND user_id = ? + """) + ) { + memberPermissionsStatement.setString(1, projectId); + memberPermissionsStatement.setString(2, request.userId()); + ResultSet memberPermissionsResult = memberPermissionsStatement.executeQuery(); + Permissions memberPermissions = new Permissions(memberPermissionsResult.getLong(1)); + + // If a non-administrator attempts to remove an administrator, return. + if (requireUserCanModifyMember(ctx, connection, projectId, request.userId(), scopePermissions)) return; + + boolean memberIsAdmin = memberPermissions.hasPermissions(Permission.ADMINISTRATOR); + + // If the member can edit the project, check if there are any other project editors left within the project to avoid a situation where nobody is able to edit the project. + // check if admin can edit admin and other admin exist, and we are admin this scope + if (memberIsAdmin && scopePermissions.hasPermissions(Permission.ADMINISTRATOR)) { + permissionCountStatement.setString(1, projectId); + ResultSet permissionCountResult = permissionCountStatement.executeQuery(); + if (permissionCountResult.getInt(1) < 2) { + ctx.status(400); + ctx.result("A project must have at least one administrator"); + return; + } + } + + deleteStatement.setString(1, projectId); + deleteStatement.setString(2, request.userId()); + deleteStatement.executeUpdate(); + } + } + + @NotNull + @Override + protected String getProjectId(Context ctx) { + return ctx.pathParam("project_id"); + } + + public record Request(String userId) { + public static final Codec CODEC = User.ID_CODEC.xmap(Request::new, Request::userId); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetPermissionsEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetPermissionsEndpoint.java new file mode 100644 index 0000000..5add67c --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetPermissionsEndpoint.java @@ -0,0 +1,72 @@ +package net.modgarden.backend.endpoint.v2.project.member; + +import com.mojang.serialization.Codec; +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.data.user.User; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.PUT; + +@EndpointMethod(PUT) +@EndpointPath("/v2/project/{project_id}/set_permissions") +public class SetPermissionsEndpoint extends AuthorizedProjectEndpoint { + public SetPermissionsEndpoint() { + super("{project_id}/set_permissions", true); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; + + String projectId = ctx.pathParam("project_id"); + + Request request = decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + + if (request == null) return; + + try ( + var connection = this.getDatabaseConnection(); + var updateStatement = connection.prepareStatement(""" + UPDATE project_roles + SET permissions = ? + WHERE project_id = ? AND user_id = ? + """) + ) { + for (Map.Entry usersToPermissions : request.usersToPermissions().entrySet()) { + if (requireUserCanModifyMember( + ctx, + connection, + projectId, + usersToPermissions.getKey(), + scopePermissions + )) return; + + updateStatement.setLong(1, usersToPermissions.getValue().bits()); + updateStatement.setString(2, projectId); + updateStatement.setString(3, usersToPermissions.getKey()); + updateStatement.executeUpdate(); + } + } + } + + @NotNull + @Override + protected String getProjectId(Context ctx) { + return ctx.pathParam("project_id"); + } + + public record Request(Map usersToPermissions) { + public static final Codec CODEC = Codec.unboundedMap(User.ID_CODEC, Permission.STRING_PERMISSIONS_CODEC) + .xmap(Request::new, Request::usersToPermissions); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetRoleEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetRoleEndpoint.java new file mode 100644 index 0000000..bf9eec4 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetRoleEndpoint.java @@ -0,0 +1,66 @@ +package net.modgarden.backend.endpoint.v2.project.member; + +import com.mojang.serialization.Codec; +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.data.user.User; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.PUT; + +@EndpointMethod(PUT) +@EndpointPath("/v2/project/{project_id}/set_role") +public class SetRoleEndpoint extends AuthorizedProjectEndpoint { + public SetRoleEndpoint() { + super("{project_id}/set_role", true); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; + + String projectId = this.getProjectId(ctx); + + Request request = decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + + if (request == null) return; + + try ( + var connection = this.getDatabaseConnection(); + var updateStatement = connection.prepareStatement(""" + UPDATE project_roles + SET role_name = ? + WHERE project_id = ? AND user_id = ? + """) + ) { + for (Map.Entry usersToRoleName : request.usersToRoleName().entrySet()) { + if (requireUserCanModifyMember(ctx, connection, projectId, usersToRoleName.getKey(), scopePermissions)) return; + + updateStatement.setString(1, usersToRoleName.getValue()); + updateStatement.setString(2, projectId); + updateStatement.setString(3, usersToRoleName.getKey()); + updateStatement.executeUpdate(); + } + } + } + + @NotNull + @Override + protected String getProjectId(Context ctx) { + return ctx.pathParam("project_id"); + } + + public record Request(Map usersToRoleName) { + public static final Codec CODEC = Codec.unboundedMap(User.ID_CODEC, Codec.STRING) + .xmap(Request::new, Request::usersToRoleName); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java new file mode 100644 index 0000000..6db4366 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java @@ -0,0 +1,48 @@ +package net.modgarden.backend.endpoint.v2.submission; + +import io.javalin.http.Context; +import net.modgarden.backend.data.Permission; +import net.modgarden.backend.data.PermissionScope; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import net.modgarden.backend.endpoint.v2.AuthorizedSubmissionEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.sql.SQLException; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; + +@EndpointMethod(DELETE) +@EndpointPath("/v2/submission/{submission_id}") +public class DeleteSubmissionEndpoint extends AuthorizedSubmissionEndpoint { + public DeleteSubmissionEndpoint() { + super("{submission_id}", PermissionScope.ALL, false); + } + + @Override + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; + + String submissionId = ctx.pathParam("submission_id"); + + try ( + var connection = this.getDatabaseConnection(); + var statement = connection.prepareStatement(""" + DELETE FROM submissions + WHERE id = ? + """) + ) { + statement.setString(1, submissionId); + statement.executeUpdate(); + } + } + + @NotNull + @Override + protected String getProjectId(Context ctx) throws SQLException { + return this.getDatabaseAccess().getProjectIdFromSubmissionId(ctx.pathParam("submission_id")); + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java new file mode 100644 index 0000000..dfdce2f --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java @@ -0,0 +1,47 @@ +package net.modgarden.backend.endpoint.v2.submission; + +import io.javalin.http.Context; +import net.modgarden.backend.data.event.Submission; +import net.modgarden.backend.database.DatabaseAccess; +import net.modgarden.backend.endpoint.EndpointMethod; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.GET; + +@EndpointMethod(GET) +@EndpointPath("/v2/submission/{submission_id}") +public class GetSubmissionByIdEndpoint extends GetSubmissionEndpoint { + public GetSubmissionByIdEndpoint() { + super("submission/{submission_id}"); + } + + @Override + public void onRequest(@NotNull Context ctx) throws Exception { + String submissionId = ctx.pathParam("submission_id").toLowerCase(Locale.ROOT); + + try ( + var connection = this.getDatabaseConnection(); + var submissionsStatement = connection.prepareStatement(""" + SELECT 1 + FROM submissions + WHERE id = ? + """) + ) { + submissionsStatement.setString(1, submissionId); + var submissionsResult = submissionsStatement.executeQuery(); + + if (!submissionsResult.getBoolean(1)) { + ctx.result("Could not find submission '" + submissionId + "'"); + ctx.status(404); + return; + } + + Submission submission = this.getDatabaseAccess().getSubmissionFromId(submissionId); + ctx.json(submission); + ctx.status(200); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java new file mode 100644 index 0000000..3a0d48e --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java @@ -0,0 +1,14 @@ +package net.modgarden.backend.endpoint.v2.submission; + +import io.javalin.http.Context; +import net.modgarden.backend.endpoint.Endpoint; +import org.jetbrains.annotations.NotNull; + +public abstract class GetSubmissionEndpoint extends Endpoint { + public GetSubmissionEndpoint(String path) { + super(2, path); + } + + @Override + public abstract void onRequest(@NotNull Context ctx) throws Exception; +} diff --git a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java deleted file mode 100644 index ed11542..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java +++ /dev/null @@ -1,130 +0,0 @@ -package net.modgarden.backend.handler.v1; - -import com.google.gson.JsonParser; -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.profile.User; -import net.modgarden.backend.oauth.OAuthService; - -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.http.HttpResponse; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.Locale; -import java.util.Optional; - -public class RegistrationHandler { - public static void discordBotRegister(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - Body body = ctx.bodyAsClass(Body.class); - String username = body.username.map(s -> s.toLowerCase(Locale.ROOT)).orElse(null); - String displayName = body.displayName.orElse(null); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var checkDiscordIdStatement = connection.prepareStatement("SELECT 1 FROM users WHERE discord_id = ?"); - var checkUsernameStatement = connection.prepareStatement("SELECT 1 FROM users WHERE username = ?"); - var insertStatement = connection.prepareStatement("INSERT INTO users(id, username, display_name, discord_id, created, permissions) VALUES (?, ?, ?, ?, ?, ?)")) { - checkDiscordIdStatement.setString(1, body.id); - ResultSet existingDiscordUser = checkDiscordIdStatement.executeQuery(); - if (existingDiscordUser != null && existingDiscordUser.getBoolean(1)) { - ctx.result("Discord user is already registered."); - ctx.status(422); - return; - } - - if (username == null || displayName == null) { - var discordClient = OAuthService.DISCORD.authenticate(); - try (var stream = discordClient.get("users/" + body.id, HttpResponse.BodyHandlers.ofInputStream()).body(); - var reader = new InputStreamReader(stream)) { - var json = JsonParser.parseReader(reader); - if (username == null) - username = json.getAsJsonObject().get("username").getAsString(); - if (displayName == null) - displayName = json.getAsJsonObject().get("global_name").getAsString(); - } - } - - if (username == null) { - ctx.result("Could not resolve username."); - ctx.status(500); - return; - } else if (username.length() < 3) { - ctx.result("Username is too short."); - ctx.status(422); - return; - } else if (username.length() > 32) { - ctx.result("Username is too long."); - ctx.status(422); - return; - } else if (!username.matches(User.USERNAME_REGEX)) { - ctx.result("Username has invalid characters."); - ctx.status(422); - return; - } - - if (displayName == null) { - ctx.result("Could not resolve display name."); - ctx.status(500); - return; - } else if (displayName.isBlank()) { - ctx.result("Display name cannot be exclusively whitespace."); - ctx.status(422); - return; - } else if (displayName.length() > 32) { - ctx.result("Display name is too long."); - ctx.status(422); - return; - } - - checkUsernameStatement.setString(1, username); - ResultSet existingUsername = checkDiscordIdStatement.executeQuery(); - if (existingUsername != null && existingUsername.getBoolean(1)) { - ctx.result("Username '" + username + "' has been taken."); - ctx.status(422); - return; - } - - long id = User.ID_GENERATOR.next(); - - insertStatement.setString(1, Long.toString(id)); - insertStatement.setString(2, username); - insertStatement.setString(3, displayName); - insertStatement.setString(4, body.id); - insertStatement.setLong(5, System.currentTimeMillis()); - insertStatement.setLong(6, 0); - insertStatement.execute(); - } catch (SQLException | IOException | InterruptedException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - return; - } - - ctx.result("Successfully registered Mod Garden account."); - ctx.status(201); - } - - - public record Body(String id, Optional username, Optional displayName) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("id").forGetter(Body::id), - Codec.STRING.optionalFieldOf("username").forGetter(Body::username), - Codec.STRING.optionalFieldOf("display_name").forGetter(Body::displayName) - ).apply(inst, Body::new)); - } -} diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java deleted file mode 100644 index 0c233ac..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java +++ /dev/null @@ -1,141 +0,0 @@ -package net.modgarden.backend.handler.v1.discord; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.LinkCode; -import net.modgarden.backend.data.profile.User; - -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.Locale; - -public class DiscordBotLinkHandler { - public static void link(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - Body body = ctx.bodyAsClass(Body.class); - - String capitalisedService = body.service.substring(0, 1).toUpperCase(Locale.ROOT) + body.service.substring(1); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var checkStatement = connection.prepareStatement("SELECT account_id FROM link_codes WHERE code = ? AND service = ?"); - var deleteStatement = connection.prepareStatement("DELETE FROM link_codes WHERE code = ? AND service = ?")) { - checkStatement.setString(1, body.linkCode); - checkStatement.setString(2, body.service); - ResultSet checkResult = checkStatement.executeQuery(); - String accountId = checkResult.getString(1); - - deleteStatement.setString(1, body.linkCode); - deleteStatement.setString(2, body.service); - deleteStatement.execute(); - if (accountId == null) { - ctx.result("Invalid link code for " + capitalisedService + "."); - ctx.status(400); - return; - } - - if (body.service.equals(LinkCode.Service.MODRINTH.serializedName())) { - handleModrinth(ctx, connection, body.discordId, accountId); - return; - } else if (body.service.equals(LinkCode.Service.MINECRAFT.serializedName())) { - handleMinecraft(ctx, connection, body.discordId, accountId); - DiscordBotOAuthHandler.invalidateFromUuid(body.linkCode); - return; - } - ctx.result("Invalid link code service '" + capitalisedService + "'."); - ctx.status(400); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - private static void handleModrinth(Context ctx, - Connection connection, - String discordId, - String accountId) throws SQLException { - try (var accountCheckStatement = connection.prepareStatement("SELECT 1 FROM users WHERE modrinth_id = ?"); - var userCheckStatement = connection.prepareStatement("SELECT 1 FROM users WHERE discord_id = ? AND modrinth_id IS NOT NULL"); - var insertStatement = connection.prepareStatement("UPDATE users SET modrinth_id = ? WHERE discord_id = ?")) { - accountCheckStatement.setString(1, accountId); - ResultSet accountCheckResult = accountCheckStatement.executeQuery(); - if (accountCheckResult.isBeforeFirst() && accountCheckResult.getBoolean(1)) { - ctx.result("The specified Modrinth account has already been linked to a Mod Garden account."); - ctx.status(400); - return; - } - - userCheckStatement.setString(1, discordId); - ResultSet userCheckResult = userCheckStatement.executeQuery(); - if (userCheckResult.isBeforeFirst() && userCheckResult.getBoolean(1)) { - ctx.result("The specified Mod Garden account is already linked with Modrinth."); - ctx.status(400); - return; - } - - insertStatement.setString(1, accountId); - insertStatement.setString(2, discordId); - insertStatement.execute(); - - ctx.result("Successfully linked Modrinth account to Mod Garden account associated with Discord ID '" + discordId + "'."); - ctx.status(201); - } - } - - private static void handleMinecraft(Context ctx, - Connection connection, - String discordId, - String uuid) throws SQLException { - try (var accountCheckStatement = connection.prepareStatement("SELECT user_id FROM minecraft_accounts WHERE uuid = ?"); - var insertStatement = connection.prepareStatement("INSERT INTO minecraft_accounts (uuid, user_id) VALUES (?, ?)")) { - User user = User.query(discordId, "discord"); - if (user == null) { - ctx.result("Could not find user from Discord ID '" + discordId + "'."); - ctx.status(400); - return; - } - - accountCheckStatement.setString(1, uuid); - ResultSet accountCheckResult = accountCheckStatement.executeQuery(); - if (accountCheckResult.isBeforeFirst() && accountCheckResult.getString(1) != null) { - if (accountCheckResult.getString(1).equals(user.id())) { - ctx.result("Your Minecraft account is already linked to your Mod Garden account."); - ctx.status(200); - return; - } - ctx.result("The specified Minecraft account has already been linked to a Mod Garden account."); - ctx.status(400); - return; - } - - insertStatement.setString(1, uuid); - insertStatement.setString(2, user.id()); - insertStatement.execute(); - - ctx.result("Successfully linked Minecraft account to Mod Garden account associated with Discord ID '" + discordId + "'."); - ctx.status(201); - } - } - - public record Body(String discordId, String linkCode, String service) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("discord_id").forGetter(Body::discordId), - Codec.STRING.fieldOf("link_code").forGetter(Body::linkCode), - Codec.STRING.fieldOf("service").forGetter(Body::service) - ).apply(inst, Body::new)); - } -} diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java deleted file mode 100644 index 9a4ceeb..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ /dev/null @@ -1,459 +0,0 @@ -package net.modgarden.backend.handler.v1.discord; - -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import com.google.gson.*; -import io.javalin.http.Context; -import io.jsonwebtoken.Jwts; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.LinkCode; -import net.modgarden.backend.oauth.OAuthService; -import net.modgarden.backend.util.AuthUtil; - -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.URI; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.security.*; -import java.security.spec.X509EncodedKeySpec; -import java.util.*; -import java.util.concurrent.TimeUnit; - -public class DiscordBotOAuthHandler { - public static void authModrinthAccount(Context ctx) { - String code = ctx.queryParam("code"); - if (code == null) { - ctx.status(400); - ctx.result("Modrinth access code is not specified."); - return; - } - - var authClient = OAuthService.MODRINTH.authenticate(); - try { - var tokenResponse = authClient.post("_internal/oauth/token", - HttpRequest.BodyPublishers.ofString(AuthUtil.createBody(getModrinthAuthorizationBody(code))), - HttpResponse.BodyHandlers.ofInputStream(), - "Content-Type", "application/x-www-form-urlencoded", - "Authorization", ModGardenBackend.DOTENV.get("MODRINTH_OAUTH_SECRET") - ); - String token; - try (InputStreamReader reader = new InputStreamReader(tokenResponse.body())) { - JsonElement tokenJson = JsonParser.parseReader(reader); - if (!tokenJson.isJsonObject() || !tokenJson.getAsJsonObject().has("access_token")) { - ctx.status(400); - ctx.result("Invalid Modrinth access token."); - return; - } - token = tokenJson.getAsJsonObject().get("access_token").getAsString(); - } - - var userResponse = authClient.get("v2/user", - HttpResponse.BodyHandlers.ofInputStream(), - "Content-Type", "application/x-www-form-urlencoded", - "Authorization", token - ); - - String userId; - try (InputStreamReader reader = new InputStreamReader(userResponse.body())) { - JsonElement userJson = JsonParser.parseReader(reader); - if (!userJson.isJsonObject() || !userJson.getAsJsonObject().has("id")) { - ctx.status(500); - ctx.result("Failed to get user id from Modrinth access token."); - return; - } - userId = userJson.getAsJsonObject().get("id").getAsString(); - } - String linkToken = AuthUtil.insertTokenIntoDatabase(ctx, userId, LinkCode.Service.MODRINTH); - if (linkToken == null) { - ctx.status(500); - ctx.result("Internal error whilst generating token."); - return; - } - ctx.status(200); - ctx.result("Successfully created link code for Modrinth account.\n\n" + - "Your link code is: " + linkToken + "\n\n" + - "This code will expire when used or in approximately 15 minutes.\n\n" + - "Please return to Discord for Step 2."); - } catch (IOException | InterruptedException ex) { - ModGardenBackend.LOG.error("Failed to handle Modrinth OAuth response.", ex); - ctx.status(500); - ctx.result("Internal error."); - } - } - - private static final Cache LINK_CODE_TO_CODE_CHALLENGE = CacheBuilder.newBuilder() - .expireAfterWrite(15, TimeUnit.MINUTES) - .build(); - private static final Cache CODE_CHALLENGE_TO_VERIFIER = CacheBuilder.newBuilder() - .expireAfterWrite(15, TimeUnit.MINUTES) - .build(); - - public static void invalidateFromUuid(String linkCode) { - String codeChallenge = LINK_CODE_TO_CODE_CHALLENGE.getIfPresent(linkCode); - if (codeChallenge != null) { - CODE_CHALLENGE_TO_VERIFIER.invalidate(codeChallenge); - } - LINK_CODE_TO_CODE_CHALLENGE.invalidate(linkCode); - } - - public static void getMicrosoftCodeChallenge(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - ctx.status(200); - try { - ctx.result(createCodeChallenge()); - } catch (NoSuchAlgorithmException ex) { - ModGardenBackend.LOG.error("Failed to generate code challenge.", ex); - ctx.result("Failed to generate code challenge, this shouldn't happen."); - ctx.status(500); - } - } - - public static String createCodeChallenge() throws NoSuchAlgorithmException { - byte[] bytes = new byte[32]; - new SecureRandom().nextBytes(bytes); - var codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - - String codeChallenge; - codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256") - .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII))); - - CODE_CHALLENGE_TO_VERIFIER.put(codeChallenge, codeVerifier); - - ModGardenBackend.LOG.debug("Code Verifier: {}", codeVerifier); - ModGardenBackend.LOG.debug("Code Challenge: {}", codeChallenge); - - return codeChallenge; - } - - private static PublicKey minecraftPublicKey = null; - - public static void authMinecraftAccount(Context ctx) { - String code = ctx.queryParam("code"); - if (code == null) { - ctx.status(400); - ctx.result("Microsoft access code is not specified."); - return; - } - - String codeChallenge = ctx.queryParam("state"); - if (codeChallenge == null) { - ctx.status(400); - ctx.result("Code challenge state is not specified."); - return; - } - String verifier = CODE_CHALLENGE_TO_VERIFIER.getIfPresent(codeChallenge); - if (verifier == null) { - ctx.status(400); - ctx.result("Code challenge verifier has expired. Please retry."); - return; - } - - try { - String microsoftToken = null; - var microsoftTokenRequest = HttpRequest.newBuilder(URI.create("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")) - .header("Content-Type", "application/x-www-form-urlencoded") - .headers("Origin", ModGardenBackend.URL + "/v1/discord/oauth/minecraft") - .POST(HttpRequest.BodyPublishers.ofString(AuthUtil.createBody(getMicrosoftAuthorizationBody(code, verifier)))); - var microsoftTokenResponse = ModGardenBackend.HTTP_CLIENT.send(microsoftTokenRequest.build(), HttpResponse.BodyHandlers.ofInputStream()); - - try (InputStreamReader microsoftTokenReader = new InputStreamReader(microsoftTokenResponse.body())) { - JsonElement microsoftTokenJson = JsonParser.parseReader(microsoftTokenReader); - if (microsoftTokenJson.isJsonObject()) { - JsonPrimitive accessToken = microsoftTokenJson.getAsJsonObject().getAsJsonPrimitive("access_token"); - if (accessToken != null && accessToken.isString()) { - microsoftToken = accessToken.getAsString(); - } - } - } - - if (microsoftToken == null) { - ctx.status(500); - ctx.result("Failed to get Microsoft access token from OAuth code."); - return; - } - - String xblToken = null; - String userHash = null; - var xblUserRequest = HttpRequest.newBuilder(URI.create("https://user.auth.xboxlive.com/user/authenticate")) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(getXboxLiveAuthenticationBody(microsoftToken))); - var xblUserResponse = ModGardenBackend.HTTP_CLIENT.send(xblUserRequest.build(), HttpResponse.BodyHandlers.ofInputStream()); - - try (InputStreamReader xblUserReader = new InputStreamReader(xblUserResponse.body())) { - JsonElement xblUserJson = JsonParser.parseReader(xblUserReader); - if (xblUserJson.isJsonObject()) { - JsonElement token = xblUserJson.getAsJsonObject().get("Token"); - if (token != null && token.isJsonPrimitive() && token.getAsJsonPrimitive().isString()) { - xblToken = token.getAsString(); - } - JsonElement displayClaims = xblUserJson.getAsJsonObject().get("DisplayClaims"); - if (displayClaims != null && displayClaims.isJsonObject() && displayClaims.getAsJsonObject().get("xui").isJsonArray()) { - JsonArray xui = displayClaims.getAsJsonObject().getAsJsonArray("xui"); - JsonElement uhs = xui.get(0); - if (uhs.isJsonObject() && uhs.getAsJsonObject().getAsJsonPrimitive("uhs").isString()) { - userHash = uhs.getAsJsonObject().getAsJsonPrimitive("uhs").getAsString(); - } - } - } - } - - if (xblToken == null) { - ctx.status(500); - ctx.result("Failed to get Xbox Live access token from Microsoft access token."); - return; - } - if (userHash == null) { - ctx.status(500); - ctx.result("Failed to get user hash from Microsoft access token."); - return; - } - - String xstsToken = null; - var xblXstsRequest = HttpRequest.newBuilder(URI.create("https://xsts.auth.xboxlive.com/xsts/authorize")) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(getXboxLiveAuthorizationBody(xblToken))); - var xblXstsResponse = ModGardenBackend.HTTP_CLIENT.send(xblXstsRequest.build(), HttpResponse.BodyHandlers.ofInputStream()); - if (xblXstsResponse.statusCode() == 401) { - String errorResponse = "Could not authorize with Xbox Live."; - try (InputStreamReader xerrReader = new InputStreamReader(xblXstsResponse.body())) { - JsonElement xerrJson = JsonParser.parseReader(xerrReader); - if (xerrJson.isJsonObject()) { - JsonPrimitive xErr = xerrJson.getAsJsonObject().getAsJsonPrimitive("xErr"); - if (xErr.isNumber()) { - long err = xErr.getAsLong(); - if (err == 2148916227L) { - errorResponse = "You are banned from Xbox."; - } - if (err == 2148916233L) { - errorResponse = "You do not have an Xbox account."; - } - if (err == 2148916235L) { - errorResponse = "This account is from a country where Xbox Live is not available or banned."; - } - if (err == 2148916236L || err == 2148916237L) { - errorResponse = "This account needs adult verification on the Xbox page. (Required in South Korea)"; - } - if (err == 2148916238L) { - errorResponse = "This account is owned by somebody under 18 years old and cannot proceed unless added to a family by an adult."; - } - } - } - } - ctx.status(401); - ctx.result(errorResponse); - return; - } - - try (InputStreamReader xblXstsReader = new InputStreamReader(xblXstsResponse.body())) { - JsonElement xblXstsJson = JsonParser.parseReader(xblXstsReader); - if (xblXstsJson.isJsonObject()) { - JsonPrimitive token = xblXstsJson.getAsJsonObject().getAsJsonPrimitive("Token"); - if (token.getAsJsonPrimitive().isString()) { - xstsToken = token.getAsString(); - } - JsonObject displayClaims = xblXstsJson.getAsJsonObject().getAsJsonObject("DisplayClaims"); - JsonArray xui = displayClaims.getAsJsonArray("xui"); - JsonElement uhs = xui.get(0); - if (uhs.isJsonPrimitive() && uhs.getAsJsonPrimitive().isString()) { - if (!uhs.getAsString().equals(userHash)) { - ctx.status(500); - ctx.result("User hash between authentication and authorization do not match."); - return; - } - } - } - } - - if (xstsToken == null) { - ctx.status(500); - ctx.result("Failed to get XSTS token from Microsoft access token."); - return; - } - - var minecraftServices = OAuthService.MINECRAFT_SERVICES.authenticate(); - - String minecraftAccessToken = null; - var minecraftAuthResponse = minecraftServices.post( - "authentication/login_with_xbox", - HttpRequest.BodyPublishers.ofString(getMinecraftAuthenticationBody(userHash, xstsToken)), - HttpResponse.BodyHandlers.ofInputStream(), - "Content-Type", "application/json", - "Accept", "application/json" - ); - - try (InputStreamReader minecraftAuthReader = new InputStreamReader(minecraftAuthResponse.body())) { - JsonElement minecraftAuthJson = JsonParser.parseReader(minecraftAuthReader); - if (minecraftAuthJson.isJsonObject()) { - JsonPrimitive accessToken = minecraftAuthJson.getAsJsonObject().getAsJsonPrimitive("access_token"); - if (accessToken.isString()) { - minecraftAccessToken = accessToken.getAsString(); - } - } - } - if (minecraftAccessToken == null) { - ctx.status(500); - ctx.result("Internal error whilst generating token."); - return; - } - - boolean ownsGame = false; - var entitlementsResponse = minecraftServices.get("entitlements/mcstore", - HttpResponse.BodyHandlers.ofInputStream(), - "Authorization", "Bearer " + minecraftAccessToken); - try (InputStreamReader entitlementsReader = new InputStreamReader(entitlementsResponse.body())) { - JsonElement minecraftEntitlementsJson = JsonParser.parseReader(entitlementsReader); - - if (minecraftEntitlementsJson.isJsonObject()) { - JsonArray items = minecraftEntitlementsJson.getAsJsonObject().getAsJsonArray("items"); - Optional javaSignaturePrimitive = items.asList().stream().filter(jsonElement -> { - if (!jsonElement.isJsonObject()) - return false; - JsonPrimitive name = jsonElement.getAsJsonObject().getAsJsonPrimitive("name"); - if (name == null || !name.isString()) - return false; - return "product_minecraft".equals(name.getAsString()); - }).map(jsonElement -> jsonElement.getAsJsonObject().getAsJsonPrimitive("signature")).filter(Objects::nonNull).findAny(); - - if (javaSignaturePrimitive.isPresent() && javaSignaturePrimitive.get().isString()) { - String javaSignature = javaSignaturePrimitive.get().getAsString(); - - if (minecraftPublicKey == null) { - try (InputStream resource = ModGardenBackend.class.getResourceAsStream("/mojang_public.key")) { - if (resource == null) { - ctx.status(500); - ctx.result("Mojang public key is not specified internally."); - return; - } - String key = new String(resource.readAllBytes(), StandardCharsets.UTF_8); - - key = key.replace("-----BEGIN PUBLIC KEY-----", "") - .replaceAll("\n", "") - .replaceAll("\r", "") - .replace("-----END PUBLIC KEY-----", ""); - - byte[] bytes = Base64.getDecoder().decode(key); - var keyFactory = KeyFactory.getInstance("RSA"); - var keySpec = new X509EncodedKeySpec(bytes); - minecraftPublicKey = keyFactory.generatePublic(keySpec); - } - } - - try { - Jwts.parserBuilder() - .setSigningKey(minecraftPublicKey) - .build() - .parseClaimsJws(javaSignature); - ownsGame = true; - } catch (Exception ignored) { - // The account cannot be verified with Mojang's publickey, therefore they probably don't own the game. - } - } - } - } - - if (!ownsGame) { - ctx.status(401); - ctx.result("You do not own a copy of Minecraft. Please purchase a copy of the game to proceed."); - return; - } - - String uuid = null; - var minecraftProfileResponse = minecraftServices.get("minecraft/profile", - HttpResponse.BodyHandlers.ofInputStream(), - "Authorization", "Bearer " + minecraftAccessToken); - try (InputStreamReader minecraftProfileReader = new InputStreamReader(minecraftProfileResponse.body())) { - JsonElement minecraftProfileJson = JsonParser.parseReader(minecraftProfileReader); - if (minecraftProfileJson.isJsonObject()) { - uuid = minecraftProfileJson.getAsJsonObject().getAsJsonPrimitive("id").getAsString(); - } - } - if (uuid == null) { - ctx.status(500); - ctx.result("Internal error whilst generating token."); - return; - } - - String linkToken = AuthUtil.insertTokenIntoDatabase(ctx, uuid, LinkCode.Service.MINECRAFT); - if (linkToken == null) { - ctx.status(500); - ctx.result("Internal error whilst generating token."); - return; - } - LINK_CODE_TO_CODE_CHALLENGE.put(linkToken, codeChallenge); - ctx.status(200); - ctx.result("Successfully created link code for Minecraft account.\n\n" + - "Your link code is: " + linkToken + "\n\n" + - "This code will expire when used or in approximately 15 minutes.\n\n" + - "Please return to Discord for Step 2."); - } catch (Exception ex) { - ModGardenBackend.LOG.error("Failed to handle Minecraft OAuth response.", ex); - ctx.status(500); - ctx.result("Internal error."); - } - } - - private static Map getModrinthAuthorizationBody(String code) { - var body = new HashMap(); - body.put("code", code); - body.put("client_id", OAuthService.MODRINTH.clientId); - body.put("redirect_uri", ModGardenBackend.URL + "/v1/discord/oauth/modrinth"); - body.put("grant_type", "authorization_code"); - return body; - } - - private static Map getMicrosoftAuthorizationBody(String code, String verifier) { - var body = new HashMap(); - body.put("code", code); - body.put("client_id", OAuthService.MINECRAFT_SERVICES.clientId); - body.put("scope", "XboxLive.signIn"); - body.put("grant_type", "authorization_code"); - body.put("redirect_uri", ModGardenBackend.URL + "/v1/discord/oauth/minecraft"); - body.put("code_verifier", verifier); - return body; - } - - private static String getXboxLiveAuthenticationBody(String code) { - var body = new JsonObject(); - var properties = new JsonObject(); - - properties.addProperty("AuthMethod", "RPS"); - properties.addProperty("SiteName", "user.auth.xboxlive.com"); - properties.addProperty("RpsTicket", "d=" + code); - - body.add("Properties", properties); - body.addProperty("RelyingParty", "http://auth.xboxlive.com"); - body.addProperty("TokenType", "JWT"); - - return body.toString(); - } - - private static String getXboxLiveAuthorizationBody(String xblToken) { - var body = new JsonObject(); - var properties = new JsonObject(); - - var userTokens = new JsonArray(); - userTokens.add(xblToken); - - properties.addProperty("SandboxId", "RETAIL"); - properties.add("UserTokens", userTokens); - - body.add("Properties", properties); - body.addProperty("RelyingParty", "rp://api.minecraftservices.com/"); - body.addProperty("TokenType", "JWT"); - - return body.toString(); - } - - private static String getMinecraftAuthenticationBody(String userHash, String xstsToken) { - var body = new JsonObject(); - body.addProperty("identityToken", "XBL3.0 x=" + userHash + ";" + xstsToken); - return body.toString(); - } -} diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java deleted file mode 100644 index c6ccdbb..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java +++ /dev/null @@ -1,305 +0,0 @@ -package net.modgarden.backend.handler.v1.discord; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.profile.User; - -import java.sql.*; - -public class DiscordBotProfileHandler { - public static void modifyUsername(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - PostBody body = ctx.bodyAsClass(PostBody.class); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var selectStatement = connection.prepareStatement("SELECT username FROM users WHERE discord_id = ?"); - var existingUserStatement = connection.prepareStatement("SELECT 1 FROM users WHERE username = ?"); - var updateStatement = connection.prepareStatement("UPDATE users SET username = ? WHERE discord_id = ?")) { - selectStatement.setString(1, body.discordId); - ResultSet oldUserResult = selectStatement.executeQuery(); - String oldUsername = oldUserResult.getString("username"); - - - if (body.value.length() < 3) { - ctx.result("Username is too short."); - ctx.status(400); - return; - } - if (body.value.length() > 32) { - ctx.result("Username is too long."); - ctx.status(400); - return; - } - if (!body.value.matches(User.USERNAME_REGEX)) { - ctx.result("Username has invalid characters."); - ctx.status(400); - return; - } - if (body.value.equals(oldUsername)) { - ctx.result("Your username is already '" + body.value + "'."); - ctx.status(400); - return; - } - - existingUserStatement.setString(1, body.value); - ResultSet existingUser = existingUserStatement.executeQuery(); - if (existingUser.getBoolean(1)) { - ctx.result("Username '" + body.value + " ' has already been taken."); - ctx.status(400); - return; - } - - updateStatement.setString(1, body.value); - updateStatement.setString(2, body.discordId); - updateStatement.execute(); - - ModGardenBackend.LOG.debug("Changed Discord user {}'s Mod Garden username to {}.", body.discordId, body.value); - ctx.result("Successfully changed your username from '" + oldUsername + "' to '" + body.value + "'."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public static void modifyDisplayName(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - PostBody body = ctx.bodyAsClass(PostBody.class); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var selectStatement = connection.prepareStatement("SELECT display_name FROM users WHERE discord_id = ?"); - var updateStatement = connection.prepareStatement("UPDATE users SET display_name = ? WHERE discord_id = ?")) { - selectStatement.setString(1, body.discordId); - ResultSet oldUserResult = selectStatement.executeQuery(); - String oldDisplayName = oldUserResult.getString("display_name"); - - if (body.value.isBlank()) { - ctx.result("Display name cannot be exclusively whitespace."); - ctx.status(400); - return; - } - if (body.value.length() > 32) { - ctx.result("Display name is too long."); - ctx.status(400); - return; - } - - updateStatement.setString(1, body.value); - updateStatement.setString(2, body.discordId); - updateStatement.execute(); - - ModGardenBackend.LOG.debug("Changed Discord user {}'s Mod Garden display name to {}.", body.discordId, body.value); - ctx.result("Successfully changed your display name from '" + oldDisplayName + "' to '" + body.value + "'."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public static void modifyPronouns(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - PostBody body = ctx.bodyAsClass(PostBody.class); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var selectStatement = connection.prepareStatement("SELECT pronouns FROM users WHERE discord_id = ?"); - var updateStatement = connection.prepareStatement("UPDATE users SET pronouns = ? WHERE discord_id = ?")) { - selectStatement.setString(1, body.discordId); - ResultSet oldUserResult = selectStatement.executeQuery(); - String oldPronouns = oldUserResult.getString("pronouns"); - - if (body.value.isBlank()) { - ctx.result("Pronouns cannot be exclusively whitespace."); - ctx.status(400); - return; - } - if (body.value.equals(oldPronouns)) { - ctx.result("Your pronouns are already '" + body.value + "'."); - ctx.status(200); - return; - } - - updateStatement.setString(1, body.value); - updateStatement.setString(2, body.discordId); - updateStatement.execute(); - - ModGardenBackend.LOG.debug("Changed Discord user {}'s Mod Garden pronouns to {}.", body.discordId, body.value); - ctx.result("Successfully changed your pronouns to '" + body.value + "'."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - - public static void modifyAvatarUrl(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - PostBody body = ctx.bodyAsClass(PostBody.class); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var updateStatement = connection.prepareStatement("UPDATE users SET avatar_url = ? WHERE discord_id = ?")) { - - if (!ModGardenBackend.SAFE_URL_REGEX.matches(body.value)) { - ctx.result("Avatar URL has invalid characters."); - ctx.status(400); - return; - } - - updateStatement.setString(1, body.value); - updateStatement.setString(2, body.discordId); - updateStatement.execute(); - - ModGardenBackend.LOG.debug("Changed Discord user {}'s Mod Garden avatar to {}.", body.discordId, body.value); - ctx.result("Successfully changed your avatar."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public record PostBody(String discordId, String value) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("discord_id").forGetter(PostBody::discordId), - Codec.STRING.fieldOf("value").forGetter(PostBody::value) - ).apply(inst, PostBody::new)); - } - - - - public static void removePronouns(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - DeleteBody body = ctx.bodyAsClass(DeleteBody.class); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var selectStatement = connection.prepareStatement("SELECT 1 FROM users WHERE discord_id = ? AND pronouns IS NULL"); - var updateStatement = connection.prepareStatement("UPDATE users SET pronouns = NULL WHERE discord_id = ?")) { - selectStatement.setString(1, body.discordId); - ResultSet selectSet = selectStatement.executeQuery(); - if (selectSet.getBoolean(1)) { - ctx.result("You have no pronouns associated with your profile."); - ctx.status(200); - return; - } - - updateStatement.setString(1, body.discordId); - updateStatement.execute(); - - ModGardenBackend.LOG.debug("Removed user {}'s Mod Garden pronouns.", body.discordId); - ctx.result("Successfully removed your pronouns from your account."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - - public static void removeAvatarUrl(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - DeleteBody body = ctx.bodyAsClass(DeleteBody.class); - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - var selectStatement = connection.prepareStatement("SELECT 1 FROM users WHERE discord_id = ? AND avatar_url IS NULL"); - var updateStatement = connection.prepareStatement("UPDATE users SET avatar_url = NULL WHERE discord_id = ?")) { - selectStatement.setString(1, body.discordId); - ResultSet selectSet = selectStatement.executeQuery(); - if (selectSet.getBoolean(1)) { - ctx.result("You have no avatar associated with your profile."); - ctx.status(200); - return; - } - - updateStatement.setString(1, body.discordId); - updateStatement.execute(); - - ModGardenBackend.LOG.debug("Removed user {}'s Mod Garden avatar.", body.discordId); - ctx.result("Successfully removed your avatar from your account."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public record DeleteBody(String discordId) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("discord_id").forGetter(DeleteBody::discordId) - ).apply(inst, DeleteBody::new)); - } -} diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java deleted file mode 100644 index b6bce6b..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java +++ /dev/null @@ -1,598 +0,0 @@ -package net.modgarden.backend.handler.v1.discord; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mojang.serialization.Codec; -import com.mojang.serialization.JsonOps; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.event.Event; -import net.modgarden.backend.data.event.Project; -import net.modgarden.backend.data.event.Submission; -import net.modgarden.backend.data.profile.User; -import net.modgarden.backend.oauth.OAuthService; -import net.modgarden.backend.oauth.client.OAuthClient; -import net.modgarden.backend.util.ExtraCodecs; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.http.HttpResponse; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.ZonedDateTime; -import java.time.temporal.ChronoField; -import java.util.*; -import java.util.stream.Collectors; - -public class DiscordBotSubmissionHandler { - public static final String REGEX = "^[a-z0-9!@$()`.+,_\"-]*$"; - - public static void submitModrinth(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - try (InputStream bodyStream = ctx.bodyInputStream(); - InputStreamReader bodyReader = new InputStreamReader(bodyStream)) { - Body body = Body.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(bodyReader)).getOrThrow(); - String slug = body.slug.toLowerCase(Locale.ROOT); - - if (!slug.matches(REGEX)) { - ctx.status(422); - ctx.result("Invalid Modrinth slug."); - return; - } - - User user = User.query(body.discordId, "discord"); - if (user == null || user.modrinthId().isEmpty()) { - ctx.status(422); - ctx.result("Could not find a Mod Garden or Modrinth account linked to the specified Discord user."); - return; - } - - OAuthClient modrinthClient = OAuthService.MODRINTH.authenticate(); - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement projectCheckStatement = connection.prepareStatement("SELECT id FROM projects WHERE modrinth_id = ?"); - PreparedStatement projectAuthorsCheckStatement = connection.prepareStatement("SELECT 1 FROM project_authors WHERE project_id = ? AND user_id = ?"); - PreparedStatement projectInsertStatement = connection.prepareStatement("INSERT INTO projects(id, slug, modrinth_id, attributed_to) VALUES (?, ?, ?, ?)"); - PreparedStatement projectAuthorsStatement = connection.prepareStatement("INSERT INTO project_authors(project_id, user_id) VALUES (?, ?)"); - PreparedStatement submissionCheckStatement = connection.prepareStatement("SELECT 1 FROM submissions WHERE project_id = ? AND event = ?"); - PreparedStatement submissionStatement = connection.prepareStatement("INSERT INTO submissions(id, project_id, event, modrinth_version_id, submitted) VALUES (?, ?, ?, ?, ?)")) { - - Event event = getCurrentEvent(connection); - - if (event == null) { - ctx.status(422); - ctx.result("A Mod Garden event is not currently open for submissions."); - return; - } - - var projectStream = modrinthClient.get("v2/project/" + slug, HttpResponse.BodyHandlers.ofInputStream()); - if (projectStream.statusCode() != 200) { - ctx.status(422); - ctx.result("Could not find Modrinth project."); - return; - } - - try (InputStreamReader projectReader = new InputStreamReader(projectStream.body())) { - ModrinthProject modrinthProject = ModrinthProject.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(projectReader)).getOrThrow(); - - projectCheckStatement.setString(1, modrinthProject.id); - ResultSet projectCheck = projectCheckStatement.executeQuery(); - String projectId = projectCheck.getString(1); - - if (projectId != null) { - projectAuthorsCheckStatement.setString(1, projectId); - projectAuthorsCheckStatement.setString(2, user.id()); - ResultSet authorsCheck = projectAuthorsCheckStatement.executeQuery(); - if (!authorsCheck.getBoolean(1)) { - ctx.status(401); - ctx.result("Unauthorized to submit Modrinth project '" + modrinthProject.title + "' to event '" + event.displayName() + "'."); - return; - } - } else if (!hasModrinthAttribution(ctx, - modrinthClient, - modrinthProject, - user.modrinthId().get(), - event.displayName() - )) { - return; - } - - submissionCheckStatement.setString(1, projectId); - submissionCheckStatement.setString(2, event.id()); - var submissionCheck = submissionCheckStatement.executeQuery(); - if (submissionCheck.getBoolean(1)) { - ctx.status(200); - JsonObject result = new JsonObject(); - result.addProperty("success", ctx.status().getMessage()); - result.addProperty("description", "Modrinth project '" + modrinthProject.title + "' has already been submitted to event '" + event.displayName() + "'."); - ctx.json(result); - return; - } - - ModrinthVersion modrinthVersion = getModrinthVersion(modrinthClient, modrinthProject, event.minecraftVersion(), event.loader(), null); - if (modrinthVersion == null) { - ctx.status(422); - ctx.result("Could not find a valid Modrinth version for " + toFriendlyLoaderString(event.loader()) + " on Minecraft " + event.minecraftVersion() + "."); - return; - } - - if (projectId == null) { - long generatedProjectId = Project.ID_GENERATOR.next(); - projectId = Long.toString(generatedProjectId); - projectInsertStatement.setString(1, projectId); - projectInsertStatement.setString(2, slug); - projectInsertStatement.setString(3, modrinthProject.id); - projectInsertStatement.setString(4, user.id()); - projectInsertStatement.execute(); - - // TODO: Add added project authors with valid Mod Garden accounts (outside of just being part of the org) to the project. - projectAuthorsStatement.setString(1, projectId); - projectAuthorsStatement.setString(2, user.id()); - projectAuthorsStatement.execute(); - } - - long generatedSubmissionId = Submission.ID_GENERATOR.next(); - String submissionId = Long.toString(generatedSubmissionId); - submissionStatement.setString(1, submissionId); - submissionStatement.setString(2, projectId); - submissionStatement.setString(3, event.id()); - submissionStatement.setString(4, modrinthVersion.id()); - submissionStatement.setLong(5, System.currentTimeMillis()); - submissionStatement.execute(); - - ctx.status(201); - - JsonObject result = new JsonObject(); - result.addProperty("success", ctx.status().getMessage()); - result.addProperty("description", "Submitted Modrinth project '" + modrinthProject.title + "' to event '" + event.displayName() + "'."); - ctx.json(result); - } - } - } catch (SQLException | IOException | InterruptedException ex) { - ModGardenBackend.LOG.error("Failed to submit project.", ex); - ctx.status(500); - ctx.result("Internal error."); - } - } - - - public static void setVersionModrinth(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - try (InputStream bodyStream = ctx.bodyInputStream(); - InputStreamReader bodyReader = new InputStreamReader(bodyStream)) { - Body body = Body.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(bodyReader)).getOrThrow(); - String slug = body.slug.toLowerCase(Locale.ROOT); - - if (!slug.matches(REGEX)) { - ctx.status(422); - ctx.result("Invalid Modrinth slug."); - return; - } - - User user = User.query(body.discordId, "discord"); - if (user == null) { - ctx.status(422); - ctx.result("Could not find a Mod Garden or Modrinth account linked to the specified Discord user."); - return; - } - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement selectProjectDataStatement = connection.prepareStatement("SELECT id, modrinth_id FROM projects WHERE slug = ?"); - PreparedStatement projectAuthorCheckStatement = connection.prepareStatement("SELECT 1 FROM project_authors WHERE project_id = ? AND user_id = ?"); - PreparedStatement updateSubmissionVersionStatement = connection.prepareStatement("UPDATE submissions SET modrinth_version_id = ? WHERE event = ? AND project_id = ?")) { - Event event = getNonFrozenEvent(connection); - if (event == null) { - ctx.status(422); - ctx.result("A Mod Garden event is not currently open for updating."); - return; - } - - selectProjectDataStatement.setString(1, body.slug); - ResultSet projectDataQuery = selectProjectDataStatement.executeQuery(); - - String projectId = projectDataQuery.getString("id"); - String modrinthId = projectDataQuery.getString("modrinth_id"); - - var modrinthClient = OAuthService.MODRINTH.authenticate(); - var modrinthStream = modrinthClient.get("v2/project/" + modrinthId, HttpResponse.BodyHandlers.ofInputStream()); - if (modrinthStream.statusCode() != 200) { - ctx.status(422); - ctx.result("Could not find the specified Modrinth project."); - return; - } - InputStreamReader modrinthProjectReader = new InputStreamReader(modrinthStream.body()); - ModrinthProject modrinthProject = ModrinthProject.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(modrinthProjectReader)).getOrThrow(); - - projectAuthorCheckStatement.setString(1, projectId); - projectAuthorCheckStatement.setString(2, user.id()); - ResultSet projectAuthorQuery = projectAuthorCheckStatement.executeQuery(); - - if (!projectAuthorQuery.getBoolean(1)) { - ctx.status(401); - ctx.result("Only an author of a project is authorized to change the version of the project '" + modrinthProject.title + "' from event '" + event.displayName() + "'."); - return; - } - - ModrinthVersion modrinthVersion = getModrinthVersion(modrinthClient, modrinthProject, event.minecraftVersion(), event.loader(), body.version); - if (modrinthVersion == null) { - ctx.status(422); - if (body.version != null) { - ctx.result("Could not find a valid Modrinth version '" + body.version + "' for " + toFriendlyLoaderString(event.loader()) + " on Minecraft " + event.minecraftVersion() + "."); - } else { - ctx.result("Could not find a valid Modrinth version for " + toFriendlyLoaderString(event.loader()) + " on Minecraft " + event.minecraftVersion() + "."); - } - return; - } - updateSubmissionVersionStatement.setString(1, modrinthVersion.id()); - updateSubmissionVersionStatement.setString(2, event.id()); - updateSubmissionVersionStatement.setString(3, projectId); - if (updateSubmissionVersionStatement.executeUpdate() == 0) { - ctx.status(200); - JsonObject result = new JsonObject(); - result.addProperty("success", ctx.status().getMessage()); - result.addProperty("description", "Modrinth project '" + modrinthProject.title + "' is already set to version '" + modrinthVersion.name + "'."); - ctx.json(result); - return; - } - ctx.status(201); - JsonObject result = new JsonObject(); - result.addProperty("success", ctx.status().getMessage()); - result.addProperty("description", "Successfully updated Modrinth project '" + modrinthProject.title + "' to '" + modrinthVersion.name + "' within the Mod Garden database."); - ctx.json(result); - } - } catch (Exception ex) { - ModGardenBackend.LOG.error("Failed to unsubmit project.", ex); - ctx.status(500); - ctx.result("Internal error."); - } - } - - public static void unsubmit(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - try (InputStream bodyStream = ctx.bodyInputStream(); - InputStreamReader bodyReader = new InputStreamReader(bodyStream)) { - Body body = Body.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(bodyReader)).getOrThrow(); - String slug = body.slug.toLowerCase(Locale.ROOT); - - if (!slug.matches(REGEX)) { - ctx.status(422); - ctx.result("Invalid Mod Garden slug."); - return; - } - - User user = User.query(body.discordId, "discord"); - if (user == null) { - ctx.status(422); - ctx.result("Could not find a Mod Garden or Modrinth account linked to the specified Discord user."); - return; - } - - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement getModrinthIdStatement = connection.prepareStatement("SELECT id, modrinth_id FROM projects WHERE slug = ?"); - PreparedStatement checkSubmissionStatement = connection.prepareStatement("SELECT 1 FROM submissions WHERE project_id = ? AND event = ?"); - PreparedStatement projectAttributionCheckStatement = connection.prepareStatement("SELECT 1 FROM projects WHERE slug = ? AND attributed_to = ?"); - PreparedStatement deleteSubmissionStatement = connection.prepareStatement("DELETE FROM submissions WHERE project_id = ? AND event = ?"); - PreparedStatement checkSubmissionPreDeletionStatement = connection.prepareStatement("SELECT 1 FROM submissions WHERE project_id = ?"); - PreparedStatement projectDeleteStatement = connection.prepareStatement("DELETE FROM projects WHERE id = ?"); - PreparedStatement projectAuthorsDeleteStatement = connection.prepareStatement("DELETE FROM project_authors WHERE project_id = ?")) { - Event event = getCurrentEvent(connection); - - if (event == null) { - ctx.status(422); - ctx.result("A Mod Garden event is not currently open for unsubmitting."); - return; - } - - getModrinthIdStatement.setString(1, slug); - ResultSet modrinthResult = getModrinthIdStatement.executeQuery(); - String potentialModrinthId = modrinthResult.getString("modrinth_id"); - String modrinthId = slug; - if (potentialModrinthId != null) { - modrinthId = potentialModrinthId; - } - - String projectId = modrinthResult.getString("id"); - - var modrinthStream = OAuthService.MODRINTH.authenticate().get("v2/project/" + modrinthId, HttpResponse.BodyHandlers.ofInputStream()); - String title = slug; - if (modrinthStream.statusCode() == 200) { - InputStreamReader modrinthProjectReader = new InputStreamReader(modrinthStream.body()); - ModrinthProject modrinthProject = ModrinthProject.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(modrinthProjectReader)).getOrThrow(); - title = modrinthProject.title; - } - - if (projectId == null) { - ctx.status(200); - JsonObject result = new JsonObject(); - result.addProperty("success", ctx.status().getMessage()); - result.addProperty("description", "Project '" + title + "' was never submitted to a Mod Garden event."); - ctx.json(result); - return; - } - - checkSubmissionStatement.setString(1, projectId); - checkSubmissionStatement.setString(2, event.id()); - ResultSet submissionResult = checkSubmissionStatement.executeQuery(); - if (!submissionResult.getBoolean(1)) { - ctx.status(200); - JsonObject result = new JsonObject(); - result.addProperty("success", ctx.status().getMessage()); - result.addProperty("description", "Project '" + title + "' was never submitted to event '" + event.displayName() + "'."); - ctx.json(result); - return; - } - - projectAttributionCheckStatement.setString(1, slug); - projectAttributionCheckStatement.setString(2, user.id()); - ResultSet projectAttributionResult = projectAttributionCheckStatement.executeQuery(); - if (!projectAttributionResult.getBoolean(1)) { - ctx.status(401); - ctx.result("Only the original submitter is authorized to unsubmit '" + title + "' from event '" + event.displayName() + "'."); - return; - } - - deleteSubmissionStatement.setString(1, projectId); - deleteSubmissionStatement.setString(2, event.id()); - deleteSubmissionStatement.execute(); - - checkSubmissionPreDeletionStatement.setString(1, projectId); - ResultSet submissionPreDeletionResult = checkSubmissionPreDeletionStatement.executeQuery(); - if (!submissionPreDeletionResult.getBoolean(1)) { - projectDeleteStatement.setString(1, projectId); - projectDeleteStatement.execute(); - - projectAuthorsDeleteStatement.setString(1, projectId); - projectAuthorsDeleteStatement.execute(); - } - - ctx.status(201); - - JsonObject result = new JsonObject(); - result.addProperty("success", ctx.status().getMessage()); - result.addProperty("description", "Unsubmitted project '" + title + "' from event '" + event.displayName() + "'."); - ctx.json(result); - } - } catch (Exception ex) { - ModGardenBackend.LOG.error("Failed to unsubmit project.", ex); - ctx.status(500); - ctx.result("Internal error."); - } - } - - private static String toFriendlyLoaderString(String value) { - if (value.equals("neoforge")) { - return "NeoForge"; - } - return value.substring(0, 1).toUpperCase(Locale.ROOT) + value.substring(1); - } - - private static boolean hasModrinthAttribution(Context ctx, - OAuthClient modrinthClient, - ModrinthProject project, - String userId, - String eventDisplayName) throws IOException, InterruptedException { - var membersStream = modrinthClient.get("v2/project/" + project.id + "/members", HttpResponse.BodyHandlers.ofInputStream()); - if (membersStream.statusCode() == 200) { - try (InputStreamReader membersReader = new InputStreamReader(membersStream.body())) { - JsonElement membersJson = JsonParser.parseReader(membersReader); - if (!membersJson.isJsonArray()) { - ctx.status(500); - ctx.result("Could not parse project member data."); - return false; - } - - for (JsonElement member : membersJson.getAsJsonArray()) { - if (!member.isJsonObject()) - continue; - JsonObject memberObj = member.getAsJsonObject(); - JsonObject userObj = memberObj.getAsJsonObject("user"); - if (userObj.has("id")) { - if (userId.equals(userObj.get("id").getAsString())) { - return true; - } - } - } - } - } - - if (project.organization != null) { - var organizationStream = modrinthClient.get("v3/organization/" + project.organization, HttpResponse.BodyHandlers.ofInputStream()); - try (InputStreamReader organizationReader = new InputStreamReader(organizationStream.body())) { - JsonElement organizationJson = JsonParser.parseReader(organizationReader); - if (!organizationJson.isJsonObject()) { - ctx.status(500); - ctx.result("Could not parse organization data."); - return false; - } - - JsonObject organizationObj = organizationJson.getAsJsonObject(); - for (JsonElement member : organizationObj.getAsJsonArray("members")) { - if (!member.isJsonObject()) - continue; - JsonObject memberObj = member.getAsJsonObject(); - JsonObject userObj = memberObj.getAsJsonObject("user"); - if (userObj.has("id")) { - if (userId.equals(userObj.get("id").getAsString())) { - return true; - } - } - } - } - } - - ctx.status(401); - ctx.result("Unauthorized to submit Modrinth project '" + project.title + "' to Mod Garden event '" + eventDisplayName + "'."); - return false; - } - - @Nullable - private static ModrinthVersion getModrinthVersion(OAuthClient modrinthClient, ModrinthProject modrinthProject, String minecraftVersion, String loader, @Nullable String versionString) throws IOException, InterruptedException { - if (versionString != null) { - var versionStream = modrinthClient.get("v2/project/" + modrinthProject.id + "/version/" + versionString, HttpResponse.BodyHandlers.ofInputStream()); - if (versionStream.statusCode() == 200) { - try (InputStreamReader versionReader = new InputStreamReader(versionStream.body())) { - JsonElement versionJson = JsonParser.parseReader(versionReader); - ModrinthVersion potentialVersion = ModrinthVersion.CODEC.parse(JsonOps.INSTANCE, versionJson).getOrThrow(); - - if (versionString.equals(potentialVersion.id) || versionString.equals(potentialVersion.versionNumber)) { - if (potentialVersion.loaders.contains(loader) || loader.equals("neoforge") && potentialVersion.loaders.contains("fabric")) { - return potentialVersion; - } - } - } - } - return null; - } - - List modrinthVersions = modrinthProject.versions.parallelStream().map(versionId -> { - try { - var versionStream = modrinthClient.get("v2/version/" + versionId, HttpResponse.BodyHandlers.ofInputStream()); - if (versionStream.statusCode() != 200) - return null; - - try (InputStreamReader versionReader = new InputStreamReader(versionStream.body())) { - JsonElement versionJson = JsonParser.parseReader(versionReader); - ModrinthVersion potentialVersion = ModrinthVersion.CODEC.parse(JsonOps.INSTANCE, versionJson).getOrThrow(); - - if (!potentialVersion.gameVersions.contains(minecraftVersion)) - return null; - - // Handle natively supported mods for the event's loader. - if (potentialVersion.loaders.contains(loader)) { - return potentialVersion; - // Handle Fabric mods loaded via Connector on NeoForge. - } else if (loader.equals("neoforge") && potentialVersion.loaders.contains("fabric")) { - return potentialVersion; - } - } - } catch (Exception ex) { - ModGardenBackend.LOG.error("Failed to read Modrinth version.", ex); - } - return null; - }).filter(Objects::nonNull).collect(Collectors.toCollection(ArrayList::new)); - - // Filter out non-native options if the mod has a native version. - // Handles cases like Sinytra Connector. - if (modrinthVersions.stream().anyMatch(v -> v.loaders.contains(loader))) { - modrinthVersions.removeIf(v -> !v.loaders.contains(loader)); - } - - return modrinthVersions.stream() - .max(Comparator.comparingLong(value -> value.datePublished.getLong(ChronoField.INSTANT_SECONDS))) - .orElse(null); - } - - private static Event getCurrentEvent(Connection connection) throws SQLException { - PreparedStatement slugStatement = connection.prepareStatement("SELECT slug FROM events WHERE start_time <= ? AND end_time > ? LIMIT 1"); - - long currentMillis = System.currentTimeMillis(); - slugStatement.setLong(1, currentMillis); - slugStatement.setLong(2, currentMillis); - ResultSet query = slugStatement.executeQuery(); - - @Nullable String slug = query.getString(1); - - if (slug != null) - return Event.queryFromSlug(slug); - - return null; - } - - - private static Event getNonFrozenEvent(Connection connection) throws SQLException { - PreparedStatement slugStatement = connection.prepareStatement("SELECT slug FROM events WHERE start_time <= ? AND freeze_time > ? LIMIT 1"); - - long currentMillis = System.currentTimeMillis(); - slugStatement.setLong(1, currentMillis); - slugStatement.setLong(2, currentMillis); - ResultSet query = slugStatement.executeQuery(); - - @Nullable String slug = query.getString(1); - - if (slug != null) - return Event.queryFromSlug(slug); - - return null; - } - - private record Body(String discordId, String slug, @Nullable String version) { - private static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING - .fieldOf("discord_id") - .forGetter(b -> b.discordId), - Codec.STRING - .fieldOf("slug") - .forGetter(b -> b.slug), - Codec.STRING - .optionalFieldOf("version") - .forGetter(b -> Optional.ofNullable(b.version)) - ).apply(inst, (discordId, slug, version) -> - new Body(discordId, slug, version.orElse(null)))); - - } - - private static class ModrinthProject { - protected static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("title").forGetter(p -> p.title), - Codec.STRING.fieldOf("id").forGetter(p -> p.id), - Codec.STRING.listOf().xmap(Set::copyOf, List::copyOf).fieldOf("versions").forGetter(p -> p.versions) - ).apply(inst, ModrinthProject::new)); - - protected final String title; - protected final String id; - protected final Set versions; - - @Nullable - protected String organization; - - private ModrinthProject(String title, String id, Set versions) { - this.title = title; - this.id = id; - this.versions = versions; - } - } - - private record ModrinthVersion(String id, String name, String versionNumber, ZonedDateTime datePublished, - Set gameVersions, Set loaders) { - private static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("id").forGetter(v -> v.id), - Codec.STRING.fieldOf("name").forGetter(v -> v.name), - Codec.STRING.fieldOf("version_number").forGetter(v -> v.versionNumber), - ExtraCodecs.ISO_DATE_TIME.fieldOf("date_published").forGetter(v -> v.datePublished), - Codec.STRING.listOf().xmap(Set::copyOf, List::copyOf).fieldOf("game_versions").forGetter(v -> v.gameVersions), - Codec.STRING.listOf().xmap(Set::copyOf, List::copyOf).fieldOf("loaders").forGetter(v -> v.loaders) - ).apply(inst, ModrinthVersion::new)); - } -} diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotTeamManagementHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotTeamManagementHandler.java deleted file mode 100644 index b8b0ff6..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotTeamManagementHandler.java +++ /dev/null @@ -1,314 +0,0 @@ -package net.modgarden.backend.handler.v1.discord; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.util.AuthUtil; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.util.Locale; -import java.util.Objects; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -public class DiscordBotTeamManagementHandler { - public static void sendInvite(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - InviteBody inviteBody = ctx.bodyAsClass(InviteBody.class); - String role = inviteBody.role.toLowerCase(Locale.ROOT); - - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - if (!"author".equals(role) && !"builder".equals(role)) { - ctx.result("Invalid role '" + role + "'."); - ctx.status(400); - return; - } - var checkAuthorStatement = connection.prepareStatement( - "SELECT user_id FROM project_authors WHERE project_id = ? AND user_id = ?"); - checkAuthorStatement.setString(1, inviteBody.projectId); - checkAuthorStatement.setString(2, inviteBody.userId); - var checkAuthorResult = checkAuthorStatement.executeQuery(); - if (checkAuthorResult.next()) { - ctx.result("User is already a member of the project as an author."); - ctx.status(200); - return; - } - if ("builder".equals(role)) { - var checkBuilderStatement = connection.prepareStatement( - "SELECT user_id FROM project_builders WHERE project_id = ? AND user_id = ?"); - checkBuilderStatement.setString(1, inviteBody.projectId); - checkBuilderStatement.setString(2, inviteBody.userId); - var checkBuilderResult = checkBuilderStatement.executeQuery(); - if (checkBuilderResult.next()) { - ctx.result("User is already a member of the project as a builder."); - ctx.status(200); - return; - } - } - - - var deleteDifferentTeamRoleInvitationsStatement = connection.prepareStatement( - """ - UPDATE team_invites - SET expires = ? - WHERE - project_id = ? - AND - user_id = ? - AND - role != ? - """); - deleteDifferentTeamRoleInvitationsStatement.setLong(1, getInviteExpirationTime()); - deleteDifferentTeamRoleInvitationsStatement.setString(2, inviteBody.projectId); - deleteDifferentTeamRoleInvitationsStatement.setString(3, inviteBody.userId); - deleteDifferentTeamRoleInvitationsStatement.setString(4, inviteBody.role); - deleteDifferentTeamRoleInvitationsStatement.execute(); - - var updateTeamExpiresStatement = connection.prepareStatement( - """ - UPDATE team_invites - SET expires = ? - WHERE - project_id = ? - AND - user_id = ? - """); - updateTeamExpiresStatement.setLong(1, getInviteExpirationTime()); - updateTeamExpiresStatement.setString(2, inviteBody.projectId); - updateTeamExpiresStatement.setString(3, inviteBody.userId); - int expiryCount = updateTeamExpiresStatement.executeUpdate(); - if (expiryCount > 0) { - ctx.result("Updated expiry for project invitation to a later time."); - ctx.status(201); - return; - } - var code = AuthUtil.generateRandomToken(); - var insertTeamInviteStatement = connection.prepareStatement( - "INSERT INTO team_invites (code, project_id, user_id, expires, role) VALUES (?, ?, ?, ?, ?)"); - insertTeamInviteStatement.setString(1, code); - insertTeamInviteStatement.setString(2, inviteBody.projectId); - insertTeamInviteStatement.setString(3, inviteBody.userId); - insertTeamInviteStatement.setLong(4, getInviteExpirationTime()); - insertTeamInviteStatement.setString(5, role); - insertTeamInviteStatement.execute(); - ctx.result(code); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public static void acceptInvite(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - AcceptInviteBody acceptInviteBody = ctx.bodyAsClass(AcceptInviteBody.class); - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - var checkInviteStatement = connection.prepareStatement( - "SELECT * FROM team_invites WHERE code = ?"); - checkInviteStatement.setString(1, acceptInviteBody.inviteCode); - var checkInviteResult = checkInviteStatement.executeQuery(); - if (!checkInviteResult.next()) { - ctx.result("Invalid Team Invite Code."); - ctx.status(400); - return; - } - var projectId = checkInviteResult.getString("project_id"); - var userId = checkInviteResult.getString("user_id"); - var role = checkInviteResult.getString("role"); - - var deleteInviteStatement = connection.prepareStatement( - "DELETE FROM team_invites WHERE code = ?"); - deleteInviteStatement.setString(1, acceptInviteBody.inviteCode); - deleteInviteStatement.execute(); - - if (Objects.equals(role, "author")) { - var deleteBuilderStatement = connection.prepareStatement( - "DELETE FROM project_builders WHERE project_id = ? AND user_id = ?" - ); - deleteBuilderStatement.setString(1, projectId); - deleteBuilderStatement.setString(2, userId); - deleteBuilderStatement.execute(); - - var insertAuthorStatement = connection.prepareStatement( - "INSERT INTO project_authors (project_id, user_id) VALUES (?, ?)"); - insertAuthorStatement.setString(1, projectId); - insertAuthorStatement.setString(2, userId); - insertAuthorStatement.execute(); - ctx.result("Successfully joined project as " + role + "."); - ctx.status(201); - } else if (Objects.equals(role, "builder")) { - var insertBuilderStatement = connection.prepareStatement( - "INSERT INTO project_builders (project_id, user_id) VALUES (?, ?)"); - insertBuilderStatement.setString(1, projectId); - insertBuilderStatement.setString(2, userId); - insertBuilderStatement.execute(); - ctx.result("Successfully joined project as " + role + "."); - ctx.status(201); - } else { - ctx.result("Invalid role in invite."); - ctx.status(500); - } - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public static void declineInvite(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - DeclineInviteBody declineInviteBody = ctx.bodyAsClass(DeclineInviteBody.class); - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - var checkInviteStatement = connection.prepareStatement( - "SELECT * FROM team_invites WHERE code = ?"); - checkInviteStatement.setString(1, declineInviteBody.inviteCode); - var checkInviteResult = checkInviteStatement.executeQuery(); - if (!checkInviteResult.next()) { - ctx.result("Invalid Team Invite Code."); - ctx.status(400); - return; - } - var deleteInviteStatement = connection.prepareStatement( - "DELETE FROM team_invites WHERE code = ?"); - deleteInviteStatement.setString(1, declineInviteBody.inviteCode); - deleteInviteStatement.execute(); - - ctx.result("Successfully declined invite to project."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public static void removeMember(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - // TODO: Note for rewrite, this is cursed because there are two different role tables, should be unified in the future - RemoveMemberBody removeMemberBody = ctx.bodyAsClass(RemoveMemberBody.class); - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - var deleteAuthorStatement = connection.prepareStatement( - "DELETE FROM project_authors WHERE project_id = ? AND user_id = ?"); - deleteAuthorStatement.setString(1, removeMemberBody.projectId); - deleteAuthorStatement.setString(2, removeMemberBody.userId); - int authorRowsAffected = deleteAuthorStatement.executeUpdate(); - var deleteBuilderStatement = connection.prepareStatement( - "DELETE FROM project_builders WHERE project_id = ? AND user_id = ?"); - deleteBuilderStatement.setString(1, removeMemberBody.projectId); - deleteBuilderStatement.setString(2, removeMemberBody.userId); - int builderRowsAffected = deleteBuilderStatement.executeUpdate(); - if (authorRowsAffected == 0 && builderRowsAffected == 0) { - ctx.result("User is not a member of the project."); - ctx.status(400); - return; - } - - ctx.result("Successfully removed member from project."); - ctx.status(201); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public record InviteBody(String projectId, String userId, String role) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("project_id").forGetter(InviteBody::projectId), - Codec.STRING.fieldOf("user_id").forGetter(InviteBody::userId), - Codec.STRING.fieldOf("role").forGetter(InviteBody::role) - ).apply(inst, InviteBody::new)); - } - - public record AcceptInviteBody(String inviteCode) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("invite_code").forGetter(AcceptInviteBody::inviteCode) - ).apply(inst, AcceptInviteBody::new)); - } - - public record DeclineInviteBody(String inviteCode) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("invite_code").forGetter(DeclineInviteBody::inviteCode) - ).apply(inst, DeclineInviteBody::new)); - } - - public record RemoveMemberBody(String projectId, String userId) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("project_id").forGetter(RemoveMemberBody::projectId), - Codec.STRING.fieldOf("user_id").forGetter(RemoveMemberBody::userId) - ).apply(inst, RemoveMemberBody::new)); - } - - - public static long getInviteExpirationTime() { - return (long) (Math.floor((double) (System.currentTimeMillis() + 86400000) / 86400000) * 86400000); // 24 hours later, rounded to the nearest day. - } - - public static void clearInvitesEachDay() { - new Thread(() -> { - try (ScheduledExecutorService executor = Executors.newScheduledThreadPool(1)) { - long scheduleTime = (long) (Math.floor((double) (System.currentTimeMillis() + 86400000) / 86400000) * 86400000) - System.currentTimeMillis(); - executor.schedule(() -> { - clearTokens(); - executor.schedule(AuthUtil::getTokenExpirationTime, 86400000, TimeUnit.MILLISECONDS); - }, scheduleTime, TimeUnit.MILLISECONDS); - } - }).start(); - } - - private static void clearTokens() { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement statement = connection.prepareStatement("DELETE FROM team_invites WHERE expires <= ?")) { - statement.setLong(1, System.currentTimeMillis()); - int total = statement.executeUpdate(); - ModGardenBackend.LOG.debug("Cleared {} team invite tokens.", total); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Failed to clear team invite tokens from database."); - } - } -} diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java deleted file mode 100644 index 1ce0cd1..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java +++ /dev/null @@ -1,83 +0,0 @@ -package net.modgarden.backend.handler.v1.discord; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.JsonOps; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.LinkCode; -import net.modgarden.backend.data.profile.User; -import net.modgarden.backend.util.ExtraCodecs; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.*; - -public class DiscordBotUnlinkHandler { - public static void unlink(Context ctx) { - if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { - ctx.result("Unauthorized."); - ctx.status(401); - return; - } - - if (!("application/json").equals(ctx.header("Content-Type"))) { - ctx.result("Invalid Content-Type."); - ctx.status(415); - return; - } - - Body body = ctx.bodyAsClass(Body.class); - - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { - if (body.service.equals(LinkCode.Service.MODRINTH.serializedName())) { - try (var deleteStatement = connection.prepareStatement("UPDATE users SET modrinth_id = NULL WHERE discord_id = ?")) { - deleteStatement.setString(1, body.discordId); - int resultSet = deleteStatement.executeUpdate(); - - if (resultSet == 0) { - ctx.result("Mod Garden account associated with Discord ID '" + body.discordId + "' does not have a Modrinth account linked."); - ctx.status(200); - } - - ctx.result("Successfully unlinked Modrinth account from Mod Garden account associated with Discord ID '" + body.discordId + "'."); - ctx.status(201); - } - return; - } - if (body.service.equals(LinkCode.Service.MINECRAFT.serializedName())) { - if (body.minecraftUuid.isEmpty()) { - ctx.result("'minecraft_uuid' field was not specified."); - ctx.status(400); - return; - } - - try (var deleteStatement = connection.prepareStatement("DELETE FROM minecraft_accounts WHERE uuid = ?")) { - deleteStatement.setString(1, body.minecraftUuid.get().toString().replace("-", "")); - int resultSet = deleteStatement.executeUpdate(); - - if (resultSet == 0) { - ctx.result("Mod Garden account associated with Discord ID '" + body.discordId + "' does not have the specified Minecraft account linked to it."); - ctx.status(200); - return; - } - - ctx.result("Successfully unlinked Minecraft account " + body.minecraftUuid.get() + " from Mod Garden account associated with Discord ID '" + body.discordId + "'."); - ctx.status(201); - } - } - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - ctx.result("Internal Error."); - ctx.status(500); - } - } - - public record Body(String discordId, String service, Optional minecraftUuid) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("discord_id").forGetter(Body::discordId), - Codec.STRING.fieldOf("service").forGetter(Body::service), - ExtraCodecs.UUID_CODEC.optionalFieldOf("minecraft_uuid").forGetter(Body::minecraftUuid) - ).apply(inst, Body::new)); - } -} diff --git a/src/main/java/net/modgarden/backend/oauth/OAuthService.java b/src/main/java/net/modgarden/backend/oauth/OAuthService.java index 3f1b5ae..b22f2f6 100644 --- a/src/main/java/net/modgarden/backend/oauth/OAuthService.java +++ b/src/main/java/net/modgarden/backend/oauth/OAuthService.java @@ -19,7 +19,8 @@ public enum OAuthService { DISCORD("1305609404837527612", OAuthService::authenticateDiscord), MODRINTH("Q2tuKyb4", OAuthService::authenticateModrinth), GITHUB("Iv23li4vLb7sDuZOiRmf", OAuthService::authenticateGithub), - MINECRAFT_SERVICES(" e7ee42f6-e542-4ce6-9f7b-1d31941e84c6", OAuthService::authenticateMinecraftServices); + MINECRAFT_SERVICES("e7ee42f6-e542-4ce6-9f7b-1d31941e84c6", OAuthService::authenticateMinecraftServices), + BUNNY_CDN("unused", OAuthService::authenticateBunnyCdn); public final String clientId; private final OAuthClientSupplier authSupplier; @@ -64,6 +65,10 @@ static OAuthClient authenticateMinecraftServices(String unused) { return new MinecraftServicesOAuthClient(); } + static OAuthClient authenticateBunnyCdn(String unused) { + return new BunnyCdnOAuthClient(); + } + @SuppressWarnings("unchecked") @NotNull public T authenticate() { diff --git a/src/main/java/net/modgarden/backend/oauth/client/BunnyCdnOAuthClient.java b/src/main/java/net/modgarden/backend/oauth/client/BunnyCdnOAuthClient.java new file mode 100644 index 0000000..ab27342 --- /dev/null +++ b/src/main/java/net/modgarden/backend/oauth/client/BunnyCdnOAuthClient.java @@ -0,0 +1,44 @@ +package net.modgarden.backend.oauth.client; + +import net.modgarden.backend.ModGardenBackend; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@SuppressWarnings("UastIncorrectHttpHeaderInspection") +public class BunnyCdnOAuthClient implements OAuthClient { + public static final String API_URL = "https://ny.storage.bunnycdn.com/mod-garden/"; + + @Override + public HttpResponse get(String endpoint, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + var req = HttpRequest.newBuilder(URI.create(API_URL + endpoint)) + .header("AccessKey", ModGardenBackend.DOTENV.get("BUNNY_CDN_KEY")); + if (headers.length > 0) + req.headers(headers); + + return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); + } + + @Override + public HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + var req = HttpRequest.newBuilder(URI.create(API_URL + endpoint)) + .header("AccessKey", ModGardenBackend.DOTENV.get("BUNNY_CDN_KEY")); + if (headers.length > 0) + req.headers(headers); + req.POST(bodyPublisher); + + return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); + } + + public HttpResponse put(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + var req = HttpRequest.newBuilder(URI.create(API_URL + endpoint)) + .header("AccessKey", ModGardenBackend.DOTENV.get("BUNNY_CDN_KEY")); + if (headers.length > 0) + req.headers(headers); + req.PUT(bodyPublisher); + + return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); + } +} diff --git a/src/main/java/net/modgarden/backend/oauth/client/DiscordOAuthClient.java b/src/main/java/net/modgarden/backend/oauth/client/DiscordOAuthClient.java index 61bae27..4d2f77e 100644 --- a/src/main/java/net/modgarden/backend/oauth/client/DiscordOAuthClient.java +++ b/src/main/java/net/modgarden/backend/oauth/client/DiscordOAuthClient.java @@ -30,4 +30,9 @@ public HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyP return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); } + + @Override + public HttpResponse put(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + throw new UnsupportedOperationException("PUT endpoints are not implemented for DiscordOAuthClient."); + } } diff --git a/src/main/java/net/modgarden/backend/oauth/client/GithubOAuthClient.java b/src/main/java/net/modgarden/backend/oauth/client/GithubOAuthClient.java index 726abe7..a636dd8 100644 --- a/src/main/java/net/modgarden/backend/oauth/client/GithubOAuthClient.java +++ b/src/main/java/net/modgarden/backend/oauth/client/GithubOAuthClient.java @@ -36,4 +36,9 @@ public HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyP return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); } + + @Override + public HttpResponse put(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + throw new UnsupportedOperationException("PUT endpoints are not implemented for GitHubOAuthClient."); + } } diff --git a/src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java b/src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java index 852d07e..a298342 100644 --- a/src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java +++ b/src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java @@ -28,4 +28,9 @@ public HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyP return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); } + + @Override + public HttpResponse put(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + throw new UnsupportedOperationException("PUT endpoints are not implemented for ModrinthOAuthClient."); + } } diff --git a/src/main/java/net/modgarden/backend/oauth/client/ModrinthOAuthClient.java b/src/main/java/net/modgarden/backend/oauth/client/ModrinthOAuthClient.java index 2c8710d..58845c6 100644 --- a/src/main/java/net/modgarden/backend/oauth/client/ModrinthOAuthClient.java +++ b/src/main/java/net/modgarden/backend/oauth/client/ModrinthOAuthClient.java @@ -32,4 +32,9 @@ public HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyP return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); } + + @Override + public HttpResponse put(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + throw new UnsupportedOperationException("PUT endpoints are not implemented for ModrinthOAuthClient."); + } } diff --git a/src/main/java/net/modgarden/backend/oauth/client/OAuthClient.java b/src/main/java/net/modgarden/backend/oauth/client/OAuthClient.java index 6afcd14..a777b3f 100644 --- a/src/main/java/net/modgarden/backend/oauth/client/OAuthClient.java +++ b/src/main/java/net/modgarden/backend/oauth/client/OAuthClient.java @@ -7,5 +7,7 @@ public interface OAuthClient { HttpResponse get(String endpoint, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException; - HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException; + HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException; + + HttpResponse put(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException; } diff --git a/src/main/java/net/modgarden/backend/oauth/client/OAuthClientSupplier.java b/src/main/java/net/modgarden/backend/oauth/client/OAuthClientSupplier.java index 95ad4f3..0e6b6cb 100644 --- a/src/main/java/net/modgarden/backend/oauth/client/OAuthClientSupplier.java +++ b/src/main/java/net/modgarden/backend/oauth/client/OAuthClientSupplier.java @@ -2,8 +2,6 @@ import org.jetbrains.annotations.NotNull; -import java.io.IOException; - @FunctionalInterface public interface OAuthClientSupplier { @NotNull diff --git a/src/main/java/net/modgarden/backend/util/AuthUtil.java b/src/main/java/net/modgarden/backend/util/AuthUtil.java index 1a76ddc..8874625 100644 --- a/src/main/java/net/modgarden/backend/util/AuthUtil.java +++ b/src/main/java/net/modgarden/backend/util/AuthUtil.java @@ -1,9 +1,9 @@ package net.modgarden.backend.util; import io.javalin.http.Context; -import io.seruco.encoding.base62.Base62; import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.LinkCode; +import net.modgarden.backend.endpoint.AuthorizedEndpoint; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -19,6 +19,9 @@ import java.util.stream.Collectors; public class AuthUtil { + private static final String RANDOM_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_/+=;!@#$%^&*()"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + public static String createBody(Map params) { return params.entrySet() .stream() @@ -38,12 +41,15 @@ public static String insertTokenIntoDatabase(Context ctx, String accountId, Link return token; while (token == null) { checkCodeStatement.clearParameters(); - String potential = generateRandomToken(); + String potential = AuthorizedEndpoint.generateRandomToken(); checkCodeStatement.setString(1, potential); ResultSet result = checkCodeStatement.executeQuery(); if (!result.getBoolean(1)) token = potential; } + // this almost gave me a heart attack, but no + // it's not a token. this is actually a link code. + // happy april fools insertStatement.setString(1, token); insertStatement.setString(2, accountId); insertStatement.setString(3, service.serializedName()); @@ -58,14 +64,7 @@ public static String insertTokenIntoDatabase(Context ctx, String accountId, Link return null; } - public static String generateRandomToken() { - byte[] bytes = new byte[10]; - new SecureRandom().nextBytes(bytes); - var token = new String(Base62.createInstance().encode(bytes), StandardCharsets.UTF_8); - return token.substring(0, 6); - } - - public static long getTokenExpirationTime() { + public static long getTokenExpirationTime() { return (long) (Math.floor((double) (System.currentTimeMillis() + 900000) / 900000) * 900000); // 15 minutes later } diff --git a/src/main/java/net/modgarden/backend/util/BunnyCdnUtils.java b/src/main/java/net/modgarden/backend/util/BunnyCdnUtils.java new file mode 100644 index 0000000..144391f --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/BunnyCdnUtils.java @@ -0,0 +1,75 @@ +package net.modgarden.backend.util; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.NaturalId; +import net.modgarden.backend.oauth.OAuthService; +import net.modgarden.backend.oauth.client.BunnyCdnOAuthClient; +import org.jetbrains.annotations.Nullable; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.function.Supplier; +import java.util.jar.JarFile; +import java.util.zip.ZipEntry; + +public class BunnyCdnUtils { + @Nullable + private static String uploadToCdn(String baseUrl, @Nullable InputStream imageStream) throws Exception { + if (imageStream == null) { + return null; + } + BunnyCdnOAuthClient client = OAuthService.BUNNY_CDN.authenticate(); + String uploadUrl = NaturalId.generateCdnLink(baseUrl, 5); + HttpResponse uploadResponse = client.put( + uploadUrl, + HttpRequest.BodyPublishers.ofInputStream(() -> imageStream), + HttpResponse.BodyHandlers.ofInputStream(), + "Content-Type", "application/octet-stream", + "Accept", "image/gif, image/png, image/webp" + ); + try (InputStreamReader reader = new InputStreamReader(uploadResponse.body())) { + if (uploadResponse.statusCode() != 201) { + JsonElement json = JsonParser.parseReader(reader); + String errorMessage = json.isJsonObject() && json.getAsJsonObject().has("Message") ? + json.getAsJsonObject().getAsJsonPrimitive("Message").getAsString() : + "Undefined Error."; + throw new InternalError(errorMessage); + } + } + return uploadUrl; + } + + private static InputStream getImageAsStream(@Nullable String externalDataUrl, @Nullable Supplier fmjImage) throws Exception { + if (externalDataUrl != null) { + HttpRequest httpRequest = HttpRequest.newBuilder( + URI.create(externalDataUrl) + ).build(); + HttpResponse response = ModGardenBackend.HTTP_CLIENT.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() == 200) { + return response.body(); + } + } + if (fmjImage != null) { + return fmjImage.get(); + } + return null; + } + + private static InputStream getIconAsStream(JsonObject fmj, JarFile file) throws Exception { + if (!fmj.has("icon")) { + return null; + } + String path = fmj.getAsJsonPrimitive("icon").getAsString(); + ZipEntry entry = file.getEntry(path); + if (entry != null) { + return file.getInputStream(entry); + } + return null; + } +} diff --git a/src/main/java/net/modgarden/backend/util/ExtObjectCodec.java b/src/main/java/net/modgarden/backend/util/ExtObjectCodec.java new file mode 100644 index 0000000..27d06df --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/ExtObjectCodec.java @@ -0,0 +1,77 @@ +package net.modgarden.backend.util; + +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.MapLike; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class ExtObjectCodec implements Codec { + protected ExtObjectCodec() {} + + @Override + public DataResult> decode(DynamicOps ops, T input) { + Object result = decodeResult(ops, input); + if (result != null) { + return DataResult.success(Pair.of(result, input)); + } + return DataResult.error(() -> "Failed to find a compatible data type to decode input " + input + " with."); + } + + @Override + public DataResult encode(Object input, DynamicOps ops, T prefix) { + T result = encodeResult(ops, input); + if (result != null) { + return DataResult.success(result); + } + return DataResult.error(() -> "Failed to find a compatible data type to encode input " + input + " with."); + } + + private static Object decodeResult(DynamicOps ops, T input) { + DataResult numberResult = ops.getNumberValue(input); + if (numberResult.isSuccess()) { + return numberResult.getOrThrow(); + } + DataResult stringResult = ops.getStringValue(input); + if (stringResult.isSuccess()) { + return stringResult.getOrThrow(); + } + DataResult> listResult = ops.getStream(input); + if (listResult.isSuccess()) { + return listResult.getOrThrow() + .map(t -> decodeResult(ops, t)) + .toList(); + } + DataResult> mapResult = ops.getMap(input); + if (mapResult.isSuccess()) { + return mapResult.getOrThrow() + .entries() + .map(t -> Pair.of(decodeResult(ops, t.getFirst()), decodeResult(ops, t.getSecond()))) + .collect(Collectors.toMap(Pair::getFirst, Pair::getSecond)); + } + return null; + } + + private static T encodeResult(DynamicOps ops, Object input) { + if (input instanceof Number number) { + return ops.createNumeric(number); + } + if (input instanceof String string) { + return ops.createString(string); + } + if (input instanceof List list) { + return ops.createList(list.stream() + .map(o -> encodeResult(ops, o))); + } + if (input instanceof Map map) { + return ops.createMap(map.entrySet().stream() + .map(entry -> Pair.of(encodeResult(ops, entry.getKey()), encodeResult(ops, entry.getValue())))); + } + return null; + } +} diff --git a/src/main/java/net/modgarden/backend/util/ExtraCodecs.java b/src/main/java/net/modgarden/backend/util/ExtraCodecs.java index 4b8b427..2989c38 100644 --- a/src/main/java/net/modgarden/backend/util/ExtraCodecs.java +++ b/src/main/java/net/modgarden/backend/util/ExtraCodecs.java @@ -2,21 +2,26 @@ import com.mojang.serialization.Codec; -import java.math.BigInteger; import java.time.Instant; -import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Map; import java.util.UUID; public class ExtraCodecs { - public static final Codec UUID_CODEC = Codec.STRING.xmap(string -> new UUID( - new BigInteger(string.substring(0, 16), 16).longValue(), - new BigInteger(string.substring(16), 16).longValue() - ), uuid -> uuid.toString().replace("-", "")); + public static final Codec UUID_CODEC = Codec.STRING.xmap( + UUID::fromString, + UUID::toString + ); public static final Codec ISO_DATE_TIME = Codec .withAlternative(Codec.STRING, Codec.LONG, timestamp -> ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.of("GMT")).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) ).xmap(timestamp -> ZonedDateTime.parse(timestamp, DateTimeFormatter.ISO_OFFSET_DATE_TIME), time -> time.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + public static final Codec INSTANT_CODEC = Codec.STRING.xmap( + string -> Instant.ofEpochMilli(Long.parseLong(string)), + instant -> Long.toString(instant.toEpochMilli()) + ); + + public static final Codec> EXT_CODEC = Codec.unboundedMap(Codec.STRING, new ExtObjectCodec()); } diff --git a/src/main/java/net/modgarden/backend/util/MetadataUtils.java b/src/main/java/net/modgarden/backend/util/MetadataUtils.java new file mode 100644 index 0000000..ff83059 --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/MetadataUtils.java @@ -0,0 +1,151 @@ +package net.modgarden.backend.util; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.Landing; +import net.modgarden.backend.data.Metadata; +import net.modgarden.backend.data.event.metadata.ModMetadata; +import net.modgarden.backend.oauth.OAuthService; +import net.modgarden.backend.oauth.client.ModrinthOAuthClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.*; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarFile; +import java.util.zip.ZipEntry; + +/// Imo, it's okay to hardcode this to Fabric for now. +/// Especially considering we likely won't be running events outside it any time soon, if ever. +/// @see Metadata +/// @see ModMetadata +public class MetadataUtils { + private static final String USER_AGENT = "ModGardenEvent/backend/" + Landing.getInstance().version() + " (modgarden.net)"; + + public static Metadata getMetadataFromModrinth(String modrinthProjectId, + String modrinthVersionId) throws Exception { + ModrinthOAuthClient authClient = OAuthService.MODRINTH.authenticate(); + + ExternalData externalData = ModrinthUtils.getModrinthExternalData(authClient, modrinthProjectId); + + HttpResponse versionResponse = authClient + .get( + "v3/version/" + modrinthVersionId, + HttpResponse.BodyHandlers.ofInputStream() + ); + try ( + InputStream versionStream = versionResponse.body(); + InputStreamReader versionStreamReader = new InputStreamReader(versionStream) + ) { + JsonElement potentialVersion = JsonParser.parseReader(versionStreamReader); + if (!potentialVersion.isJsonObject()) { + throw new IllegalStateException("Attempted to get a non-JSON Object Modrinth Version whilst getting project metadata."); + } + JsonObject version = potentialVersion.getAsJsonObject(); + URI primaryUri = null; + for (JsonElement potentialFile : version.getAsJsonArray("files")) { + if (potentialFile.isJsonObject()) { + JsonObject file = potentialFile.getAsJsonObject(); + if (file.getAsJsonPrimitive("primary").getAsBoolean()) { + primaryUri = URI.create(file.getAsJsonPrimitive("url").getAsString()); + break; + } + } + } + + if (primaryUri == null) { + throw new IllegalStateException("Could not find valid primary download URL from Modrinth version whilst getting project metadata."); + } + + for (JsonElement element : version.getAsJsonArray("loaders")) { + String loader = element.getAsJsonPrimitive().getAsString(); + if (loader.equals("fabric")) { + return getMetadataFromFabricModJson(primaryUri, externalData); + } + } + + throw new UnsupportedOperationException("All mod-loaders associated with the specified version are not implemented."); + } + } + + public static Metadata getMetadataFromFabricModJson(@NotNull URI jarUri, + @NotNull ExternalData externalData) throws Exception { + var request = HttpRequest.newBuilder() + .header("User-Agent", USER_AGENT) + .uri(jarUri) + .build(); + + Path temporaryFolder = Path.of("./.tmp"); + HttpResponse response = ModGardenBackend.HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofFile(temporaryFolder)); + Path temporaryFilePath = response.body(); + + Metadata metadata; + try ( + JarFile jarFile = new JarFile(temporaryFilePath.toFile()); + InputStream fmjStream = getFmjAsStream(jarFile); + InputStreamReader fmjStreamReader = new InputStreamReader(fmjStream) + ) { + JsonElement potentialFmj = JsonParser.parseReader(fmjStreamReader); + if (!potentialFmj.isJsonObject()) { + throw new IllegalStateException("Attempted to get a non-JSONObject fabric.mod.json whilst getting project metadata."); + } + + JsonObject fmj = potentialFmj.getAsJsonObject(); + + String modId = fmj.getAsJsonPrimitive("id").getAsString(); + String name = fmj.getAsJsonPrimitive("name").getAsString(); + String description = fmj.getAsJsonPrimitive("description").getAsString(); + + String sourceUrl = getFmjSourceUrl(fmj, externalData); + + metadata = new ModMetadata( + modId, + name, + description, + sourceUrl + ); + } + + if (Files.deleteIfExists(temporaryFilePath)) { + if (Files.isDirectory(temporaryFolder)) { + try (var directoryStream = Files.newDirectoryStream(temporaryFolder)) { + if (!directoryStream.iterator().hasNext()) { + Files.deleteIfExists(temporaryFolder); + } + } + } + } + + return metadata; + } + + private static InputStream getFmjAsStream(JarFile file) throws Exception { + ZipEntry entry = file.getEntry("fabric.mod.json"); + if (entry != null) { + return file.getInputStream(entry); + } + throw new NullPointerException("The specified JAR is not a Fabric mod."); + } + + private static String getFmjSourceUrl(JsonObject fmj, ExternalData data) { + if (data.externalSourceUrl() != null) { + return data.externalSourceUrl(); + } + if (fmj.has("contact")) { + JsonElement contact = fmj.getAsJsonObject("contact"); + if (contact.getAsJsonObject().has("sources")) { + return contact.getAsJsonObject().getAsJsonPrimitive("sources").getAsString(); + } + } + throw new NullPointerException("Could not find source URL from either fabric.mod.json or external data."); + } + + public record ExternalData(@Nullable String externalSourceUrl) {} +} diff --git a/src/main/java/net/modgarden/backend/util/ModrinthUtils.java b/src/main/java/net/modgarden/backend/util/ModrinthUtils.java new file mode 100644 index 0000000..c8b6d6b --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/ModrinthUtils.java @@ -0,0 +1,48 @@ +package net.modgarden.backend.util; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.modgarden.backend.oauth.client.ModrinthOAuthClient; +import org.jetbrains.annotations.NotNull; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.http.HttpResponse; + +public class ModrinthUtils { + public static MetadataUtils.ExternalData getModrinthExternalData(@NotNull ModrinthOAuthClient authClient, + @NotNull String modrinthProjectId) throws Exception { + HttpResponse projectResponse = authClient + .get( + "v3/project/" + modrinthProjectId, + HttpResponse.BodyHandlers.ofInputStream() + ); + + try ( + InputStream projectStream = projectResponse.body(); + InputStreamReader projectStreamReader = new InputStreamReader(projectStream) + ) { + JsonElement potentialProject = JsonParser.parseReader(projectStreamReader); + if (!potentialProject.isJsonObject()) { + throw new IllegalStateException("Attempted to get a non-JSON Object Modrinth Project whilst getting project metadata."); + } + JsonObject project = potentialProject.getAsJsonObject(); + + String sourceUrl = getSourceUrlFromModrinthProject(project); + + return new MetadataUtils.ExternalData(sourceUrl); + } + } + + public static String getSourceUrlFromModrinthProject(JsonObject project) { + JsonElement linkUrls = project.get("link_urls"); + if (linkUrls.isJsonObject() && linkUrls.getAsJsonObject().has("source")) { + JsonElement source = linkUrls.getAsJsonObject().get("source"); + return source.getAsJsonObject() + .getAsJsonPrimitive("url") + .getAsString(); + } + return null; + } +} diff --git a/src/main/java/net/modgarden/backend/util/ReadableOrderCodec.java b/src/main/java/net/modgarden/backend/util/ReadableOrderCodec.java new file mode 100644 index 0000000..88e630f --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/ReadableOrderCodec.java @@ -0,0 +1,435 @@ +package net.modgarden.backend.util; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.*; +import com.mojang.serialization.codecs.FieldDecoder; +import com.mojang.serialization.codecs.KeyDispatchCodec; +import com.mojang.serialization.codecs.ListCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +// TODO: Document this class. - Calico. +/// This accounts for a DFU bug where RecordCodecBuilder swaps the half-point at which members are encoded, as well as +/// moving any encoded {@link KeyDispatchCodec} based fields to the top of the encoded map, which is a change that Mojang +/// will not make because it'd mess heavily with {@link DSL#remainder()} based data fixing. +/// +/// This should only ever modify map encoding, and the encoding of lists that contain maps. +/// +/// The code below is not for children or those who are easily disturbed. +/// +/// @see Mojang/DataFixerUpper#101 +/// @param The type parameter of the root codec. +public class ReadableOrderCodec implements Codec { + private final Codec codec; + + public ReadableOrderCodec(Codec codec) { + this.codec = codec; + } + + @Override + public DataResult> decode(DynamicOps ops, T input) { + return codec.decode(ops, input); + } + + @Override + public DataResult encode(E input, DynamicOps ops, T prefix) { + return codec.encode(input, ops, prefix).map(value -> { + FieldLocationSets fieldLocations = new FieldLocationSets(new HashSet<>(), new HashSet<>()); + addToKeyDispatchFieldLocationSet(fieldLocations, ops, value, codec, null); + return tryToCorrectElement(fieldLocations, ops, value, null); + }); + } + private T tryToCorrectElement(FieldLocationSets fieldLocations, + DynamicOps ops, T element, + @Nullable FieldLocation fieldLocation) { + var listResult = ops.getStream(element).resultOrPartial(); + if (listResult.isPresent()) { + var list = new ArrayList<>(listResult.get().toList()); + for (int i = 0; i < list.size(); ++i) { + list.set(i, tryToCorrectElement( + fieldLocations, ops, list.get(i), + new FieldLocation(fieldLocation, i)) + ); + } + return ops.createList(list.stream()); + } + var mapResult = ops.getMap(element).resultOrPartial(); + if (mapResult.isPresent()) { + return correctEncoding(fieldLocations, ops, ops.mapBuilder(), mapResult.get(), fieldLocation) + .build(ops.empty()) + .resultOrPartial() + .orElse(element); + } + return element; + } + + private RecordBuilder correctEncoding(FieldLocationSets fieldLocations, DynamicOps ops, RecordBuilder builder, + MapLike newValues, @Nullable FieldLocation fieldLocation) { + List> elements = newValues.entries() + .collect(Collectors.toCollection(ArrayList::new)); + + for (var element : newValues.entries().toList()) { + String key = ops.getStringValue(element.getFirst()) + .resultOrPartial() + .orElseThrow(); + FieldLocation mappedFieldLocation = new FieldLocation(fieldLocation, key); + if (fieldLocations.keyDispatchFields.contains(mappedFieldLocation)) { + fieldLocations.keyDispatchFields.remove(mappedFieldLocation); + elements.remove(element); + builder.add(element.getFirst(), element.getSecond()); + } + } + + if (elements.size() > 4) { + if (fieldLocations.recordCodecBuilderFields.contains(fieldLocation)) { + orderRecord(builder, elements, fieldLocations, ops, fieldLocation); + } + return builder; + } + + for (Pair entry : elements) { + T key = entry.getFirst(); + T value = tryToCorrectElement( + fieldLocations, + ops, + entry.getSecond(), + new FieldLocation(fieldLocation, ops.getStringValue(key) + .resultOrPartial().orElseThrow()) + ); + builder.add(key, value); + } + + return builder; + } + + private void orderRecord(RecordBuilder builder, List> elements, + FieldLocationSets fieldLocations, DynamicOps ops, FieldLocation fieldLocation) { + if (elements.size() > 16) { + throw new UnsupportedOperationException("Unable to order RecordCodecBuilders with more than 16 values."); + } + Map orderedElements = new LinkedHashMap<>(); + if (elements.size() > 4 && elements.size() < 9) { + int divisor = (int) Math.ceil(elements.size() / 2.0); + for (int i = divisor; i < elements.size(); ++i) { + insertValueInOrderedMap(elements.get(i), orderedElements, fieldLocations, ops, fieldLocation); + } + for (int i = 0; i < divisor; ++i) { + insertValueInOrderedMap(elements.get(i), orderedElements, fieldLocations, ops, fieldLocation); + } + } else if (elements.size() > 9) { + int divisor = (int) Math.ceil(elements.size() / 2.0); + List> firstHalf = elements.subList(divisor, elements.size()); + List> secondHalf = elements.subList(0, divisor); + orderRecord(builder, firstHalf, fieldLocations, ops, fieldLocation); + orderRecord(builder, secondHalf, fieldLocations, ops, fieldLocation); + } else if (elements.size() == 9) { // Hardcode 9 here, it's a bit pesky and kinda plays by its own rules. + for (int i = 5; i < 9; ++i) { + insertValueInOrderedMap(elements.get(i), orderedElements, fieldLocations, ops, fieldLocation); + } + for (int i = 3; i < 5; ++i) { + insertValueInOrderedMap(elements.get(i), orderedElements, fieldLocations, ops, fieldLocation); + } + for (int i = 0; i < 3; ++i) { + insertValueInOrderedMap(elements.get(i), orderedElements, fieldLocations, ops, fieldLocation); + } + } else { + for (Pair element : elements) { + insertValueInOrderedMap(element, orderedElements, fieldLocations, ops, fieldLocation); + } + } + orderedElements.forEach(builder::add); + } + + private void insertValueInOrderedMap(Pair element, + Map orderedElements, + FieldLocationSets fieldLocations, + DynamicOps ops, + FieldLocation fieldLocation) { + T key = element.getFirst(); + T value = tryToCorrectElement(fieldLocations, ops, element.getSecond(), ops.getStringValue(key) + .resultOrPartial() + .map(s -> new FieldLocation(fieldLocation, s)) + .orElseThrow()); + orderedElements.put(key, value); + } + + private void addToKeyDispatchFieldLocationSet(FieldLocationSets locations, DynamicOps ops, T rootValue, + Codec codec, @Nullable FieldLocation fieldLocation) { + Codec finalCodec = mapAwayFromRecursiveCodec(codec); + if (finalCodec instanceof MapCodec.MapCodecCodec(MapCodec mapCodec)) { + if (mapCodec instanceof KeyDispatchCodec keyDispatchCodec) { + addKeyDispatchFieldLocationToSet(locations, ops, rootValue, fieldLocation, keyDispatchCodec); + return; + } + @Nullable RecordCodecBuilder recordCodecBuilder = reflectInternalBuilderFromRecordCodec(mapCodec); + if (recordCodecBuilder != null) { + MapDecoder rootMapDecoder = reflectDecoderFromRecordCodecBuilder(recordCodecBuilder); + addToKeyDispatchFieldLocationSet(locations, ops, rootValue, rootMapDecoder, fieldLocation); + } + return; + } + if (finalCodec instanceof ListCodec listCodec) { + T fieldValue = fieldLocation == null ? rootValue : fieldLocation.getEncasedField(ops, rootValue); + int fieldCount = ops.getStream(fieldValue).getOrThrow().toArray().length; + for (int i = 0; i < fieldCount; ++i) { + addToKeyDispatchFieldLocationSet(locations, ops, rootValue, listCodec.elementCodec(), new FieldLocation(fieldLocation, i)); + } + } + // We don't really have to worry about CompoundListCodec because I doubt anybody is going to actually use it. It feels more like Legacy Minecraft code imo. + } + + private void addToKeyDispatchFieldLocationSet(FieldLocationSets locations, DynamicOps ops, T rootValue, + MapDecoder rootDecoder, @Nullable FieldLocation fieldLocation) { + List> reflectedFieldsFromRecordCodecBuilder = reflectDecodersFromRecordCodecDecoder(rootDecoder, locations.recordCodecBuilderFields, fieldLocation); + if (reflectedFieldsFromRecordCodecBuilder.isEmpty()) { + locations.recordCodecBuilderFields.add(fieldLocation); + } + for (MapDecoder decoder : reflectedFieldsFromRecordCodecBuilder) { + LinkedHashSet keys = decoder.keys(JsonOps.INSTANCE) + .map(jsonElement -> jsonElement.getAsJsonPrimitive().getAsString()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + if (keys.isEmpty()) continue; + + FieldLocation newFieldLocation = new FieldLocation(fieldLocation, keys.getFirst()); + + if (!(decoder instanceof MapCodec mapCodec)) { + List> reflectedCodecs = reflectDecodersFromRecordCodecDecoder(decoder, locations.recordCodecBuilderFields, fieldLocation); + if (reflectedCodecs.isEmpty()) { + locations.recordCodecBuilderFields.add(fieldLocation); + } + for (MapDecoder innerDecoder : reflectedCodecs) { + addToKeyDispatchFieldLocationSet(locations, ops, rootValue, innerDecoder, fieldLocation); + } + continue; + } + + // Check whether the internal MapCodec is a RecordCodecBuilder. + @Nullable RecordCodecBuilder recordCodecBuilder = reflectInternalBuilderFromRecordCodec(mapCodec); + if (recordCodecBuilder != null) { + MapDecoder rootMapDecoder = reflectDecoderFromRecordCodecBuilder(recordCodecBuilder); + addToKeyDispatchFieldLocationSet(locations, ops, rootValue, rootMapDecoder, newFieldLocation); + } + + MapDecoder internalDecoder = reflectElementDecoderFromFieldMapCodec(mapCodec); + if (internalDecoder instanceof FieldDecoder fieldDecoder) { + @Nullable Codec elementCodec = mapAwayFromRecursiveCodec(reflectCodecFromFieldDecoder(fieldDecoder)); + if (elementCodec == null) continue; + addToKeyDispatchFieldLocationSet(locations, ops, rootValue, elementCodec, newFieldLocation); + } + } + } + + private void addKeyDispatchFieldLocationToSet(FieldLocationSets locations, DynamicOps ops, T rootValue, FieldLocation currentLocation, KeyDispatchCodec keyDispatchCodec) { + String fieldKey = keyDispatchCodec.keys(ops) + .map(t -> ops.getStringValue(t).resultOrPartial().orElseThrow()) + .toList() + .getFirst(); + FieldLocation dispatchTypeFieldLocation = new FieldLocation(currentLocation, fieldKey); + locations.keyDispatchFields.add(dispatchTypeFieldLocation); // We have a match! + + MapDecoder dispatchValueDecoder = reflectDecoderFromKeyDispatchCodec(ops, dispatchTypeFieldLocation.getEncasedField(ops, rootValue), keyDispatchCodec); + List> dispatchDecoders = reflectDecodersFromRecordCodecDecoder(dispatchValueDecoder, locations.recordCodecBuilderFields, currentLocation); + for (MapDecoder dispatchDecoder : dispatchDecoders) { + addToKeyDispatchFieldLocationSet(locations, ops, rootValue, dispatchDecoder, currentLocation); + } + } + + /// Represents a location within the encoded values. + private record FieldLocation(@Nullable FieldLocation previousValue, + @Nullable String key, int listIndex) { + private FieldLocation { + if (key == null && listIndex == -1) + throw new IllegalStateException("Can't create a field location without a key or a list index."); + } + + private FieldLocation(@Nullable FieldLocation previousValue, String key) { + this(previousValue, key, -1); + } + + private FieldLocation(@Nullable FieldLocation previousValue, int listIndex) { + this(previousValue, null, listIndex); + } + + public T getEncasedField(DynamicOps ops, T rootValue) { + List valueList = new ArrayList<>(); + FieldLocation addValue = this; + while (addValue != null) { + valueList.add(addValue); + addValue = addValue.previousValue; + } + Collections.reverse(valueList); + + T returnValue = rootValue; + for (FieldLocation operatingValue : valueList) { + if (operatingValue.key() != null) { + returnValue = ops.getMap(returnValue) + .resultOrPartial() + .orElseThrow() + .get(operatingValue.key()); + } else if (operatingValue.listIndex() > -1) { + returnValue = ops.getStream(returnValue) + .resultOrPartial() + .orElseThrow() + .toList() + .get(operatingValue.listIndex()); + } else { + throw new UnsupportedOperationException("Could not get specific value within map or list."); + } + } + return returnValue; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof FieldLocation(ReadableOrderCodec.FieldLocation otherPreviousValue, String otherKey, int otherListIndex))) { + return false; + } + return (previousValue == null && otherPreviousValue == null || previousValue != null && previousValue.equals(otherPreviousValue)) && + (key == null && otherKey == null || key != null && key.equals(otherKey)) && + listIndex == otherListIndex; + } + + @Override + public int hashCode() { + return Objects.hash(previousValue, key, listIndex); + } + + @NotNull + @Override + public String toString() { + if (previousValue == null) { + if (key != null) { + return key; + } + return "[" + listIndex + "]"; + } + return previousValue + (key != null ? "." + key : "") + + (listIndex != -1 ? "[" + listIndex + "]" : ""); + } + } + + private record FieldLocationSets(Set keyDispatchFields, Set recordCodecBuilderFields) {} + + private static Codec mapAwayFromRecursiveCodec(Codec codec) { + return codec instanceof Codec.RecursiveCodec recursiveCodec ? + reflectInternalCodecFromRecursiveCodec(recursiveCodec) : codec; + } + + private static Codec reflectInternalCodecFromRecursiveCodec(RecursiveCodec recursiveCodec) { + try { + Field f = recursiveCodec.getClass().getDeclaredField("wrapped"); + f.setAccessible(true); + if (f.get(recursiveCodec) instanceof Supplier supplier && supplier.get() instanceof Codec codec) { + return codec; + } + } catch (NoSuchFieldException | IllegalAccessException ignored) { + } + throw new UnsupportedOperationException("Could not obtain 'wrapped' field within RecursiveCodec."); + } + + /// Potentially gets a RecordCodecBuilder from a map codec. + /// + /// @param mapCodec A MapCodec. + /// @return The internal RecordCodecBuilder, or null if the MapCodec is not a RecordCodecBuilder based codec. + @Nullable + private static RecordCodecBuilder reflectInternalBuilderFromRecordCodec(MapCodec mapCodec) { + try { + Field f = mapCodec.getClass().getDeclaredField("val$builder"); + f.setAccessible(true); + return (RecordCodecBuilder) f.get(mapCodec); + } catch (NoSuchFieldException | IllegalAccessException ignored) { + } + return null; + } + + private static MapDecoder reflectDecoderFromRecordCodecBuilder(RecordCodecBuilder recordCodecBuilder) { + try { + Field f = recordCodecBuilder.getClass().getDeclaredField("decoder"); + f.setAccessible(true); + return (MapDecoder) f.get(recordCodecBuilder); + } catch (NoSuchFieldException | IllegalAccessException ignored) { + } + throw new UnsupportedOperationException("Could not obtain 'decoder' field within RecordCodecBuilder."); + } + + private static MapDecoder reflectElementDecoderFromFieldMapCodec(MapCodec mapCodec) { + try { + Field recordCodecBuilder = mapCodec.getClass().getDeclaredField("val$decoder"); + recordCodecBuilder.setAccessible(true); + return (MapDecoder) recordCodecBuilder.get(mapCodec); + } catch (NoSuchFieldException | IllegalAccessException ignored) { + } + return null; + } + + + private static Codec reflectCodecFromFieldDecoder(FieldDecoder mapCodec) { + try { + Field recordCodecBuilder = mapCodec.getClass().getDeclaredField("elementCodec"); + recordCodecBuilder.setAccessible(true); + return (Codec) recordCodecBuilder.get(mapCodec); + } catch (NoSuchFieldException | IllegalAccessException ignored) { + } + return null; + } + + /// Reflects all codecs from a {@link RecordCodecBuilder}. + /// + /// @param decoder The decoder to retrieve decoders from. + /// @return A list of MapDecoders obtained from the decoder. + private static List> reflectDecodersFromRecordCodecDecoder(MapDecoder decoder, Set recordCodecBuilderFields, FieldLocation currentLocation) { + List> decoders = new ArrayList<>(); + try { + for (Field field : decoder.getClass().getDeclaredFields()) { + field.setAccessible(true); + Object object = field.get(decoder); + if (object instanceof MapDecoder innerDecoder) { + decoders.addAll(reflectDecodersFromRecordCodecDecoder(innerDecoder, recordCodecBuilderFields, currentLocation)); + } else if (object instanceof RecordCodecBuilder recordCodecBuilder) { + MapDecoder innerDecoder = reflectDecoderFromRecordCodecBuilder(recordCodecBuilder); + if (field.getName().startsWith("val$function")) { + decoders.addAll(reflectDecodersFromRecordCodecDecoder(innerDecoder, recordCodecBuilderFields, currentLocation)); + } else { + decoders.add(innerDecoder); + } + recordCodecBuilderFields.add(currentLocation); + } + } + } catch (IllegalAccessException ignored) {} + return decoders; + } + + @SuppressWarnings("unchecked") + private static MapDecoder reflectDecoderFromKeyDispatchCodec(DynamicOps value, T keyValue, KeyDispatchCodec dispatchCodec) { + try { + Field keyCodecField = dispatchCodec.getClass().getDeclaredField("keyCodec"); + keyCodecField.setAccessible(true); + Object keyCodecAsObj = keyCodecField.get(dispatchCodec); + if (keyCodecAsObj instanceof Codec) { + Codec keyCodec = (Codec) keyCodecAsObj; + DataResult decodedKey = keyCodec.parse(value, keyValue); + if (!decodedKey.hasResultOrPartial()) + throw new Exception(); + K result = decodedKey.resultOrPartial().orElseThrow(); + + Field decoderField = dispatchCodec.getClass().getDeclaredField("decoder"); + decoderField.setAccessible(true); + var decoder = (Function>>) decoderField.get(dispatchCodec); + return decoder.apply(result).resultOrPartial().orElseThrow(); + } + } catch (Exception ignored) {} + throw new UnsupportedOperationException("Could not obtain either 'keyCodec' or 'decoder' field within KeyDispatchCodec."); + } +} diff --git a/src/main/java/net/modgarden/backend/util/UuidUtils.java b/src/main/java/net/modgarden/backend/util/UuidUtils.java new file mode 100644 index 0000000..61636fd --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/UuidUtils.java @@ -0,0 +1,27 @@ +package net.modgarden.backend.util; + +import java.nio.ByteBuffer; +import java.util.UUID; + +/// God damn it java +public final class UuidUtils { + private UuidUtils() {} + + public static byte[] toBytes(UUID uuid) { + ByteBuffer byteBuffer = ByteBuffer.allocate(16); + byteBuffer.putLong(uuid.getMostSignificantBits()); + byteBuffer.putLong(uuid.getLeastSignificantBits()); + return byteBuffer.array(); + } + + public static UUID fromBytes(byte[] uuid) { + ByteBuffer byteBuffer = ByteBuffer.wrap(uuid); + long mostSignificantBits = byteBuffer.getLong(); + long leastSignificantBits = byteBuffer.getLong(); + return new UUID(mostSignificantBits, leastSignificantBits); + } + + public static byte[] randomBytes() { + return toBytes(UUID.randomUUID()); + } +}