From b2ad448282ef624fd405674a2fb7c0e7e7429177 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Fri, 26 Sep 2025 23:22:00 -0400 Subject: [PATCH 01/98] chore: upgrade to JDK 25 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0171bdc..bda2e0b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ group = "net.modgarden" version = project.properties["version"].toString() java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) withJavadocJar() } From 6953663e8cb4a0e24dad13b7a1fe08c645a48afb Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 27 Sep 2025 02:35:22 -0400 Subject: [PATCH 02/98] feat: use 5 lowercase letter natural ID --- .../net/modgarden/backend/data/NaturalId.java | 51 +++++++++++++++++++ .../modgarden/backend/data/event/Event.java | 2 - .../modgarden/backend/data/event/Project.java | 4 +- .../backend/data/event/Submission.java | 4 +- .../handler/v1/RegistrationHandler.java | 6 +-- .../discord/DiscordBotSubmissionHandler.java | 9 ++-- 6 files changed, 58 insertions(+), 18 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/data/NaturalId.java 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..91841fb --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -0,0 +1,51 @@ +package net.modgarden.backend.data; + +import net.modgarden.backend.ModGardenBackend; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +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}"); + private static final Pattern PATTERN_LEGACY = Pattern.compile("[0-9]+"); + private static final String alphabet = "abcdefghijklmnopqrstuvwxyz"; + + private NaturalId() {} + + public static boolean isValid(String id) { + return PATTERN.matcher(id).hasMatch(); + } + + public static boolean isValidLegacy(String id) { + return isValid(id) || PATTERN_LEGACY.matcher(id).hasMatch(); + } + + public static String of(RandomGenerator random) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < 5; i++) { + builder.append(alphabet.charAt(random.nextInt(alphabet.length()))); + } + return builder.toString(); + } + + @NotNull + public static String generateChecked(String table, String key) throws SQLException { + String id = null; + try (Connection connection1 = ModGardenBackend.createDatabaseConnection()) { + while (id == null) { + String naturalId = of(RandomGenerator.getDefault()); + var exists = connection1.prepareStatement("SELECT true FROM ? WHERE ? = ?"); + exists.setString(1, table); + exists.setString(2, key); + exists.setString(3, naturalId); + if (exists.execute()) { + id = naturalId; + } + } + } + return id; + } +} 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..21b2bff 100644 --- a/src/main/java/net/modgarden/backend/data/event/Event.java +++ b/src/main/java/net/modgarden/backend/data/event/Event.java @@ -6,7 +6,6 @@ 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; @@ -33,7 +32,6 @@ public record Event(String id, ZonedDateTime endTime, ZonedDateTime freezeTime) { // TODO: Endpoint for creating events. - public static final SnowflakeIdGenerator ID_GENERATOR = SnowflakeIdGenerator.createDefault(1); 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), 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..2f5cd59 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -5,7 +5,6 @@ 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.data.profile.User; @@ -25,8 +24,7 @@ public record Project(String id, 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( + 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), 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..36b181b 100644 --- a/src/main/java/net/modgarden/backend/data/event/Submission.java +++ b/src/main/java/net/modgarden/backend/data/event/Submission.java @@ -6,7 +6,6 @@ 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; @@ -25,8 +24,7 @@ public record Submission(String id, 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( + 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), Project.DIRECT_CODEC.fieldOf("project").forGetter(Submission::project), diff --git a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java index ed11542..5c36a1e 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java @@ -5,6 +5,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.profile.User; import net.modgarden.backend.oauth.OAuthService; @@ -99,9 +100,7 @@ public static void discordBotRegister(Context ctx) { return; } - long id = User.ID_GENERATOR.next(); - - insertStatement.setString(1, Long.toString(id)); + insertStatement.setString(1, NaturalId.generateChecked("users", "id")); insertStatement.setString(2, username); insertStatement.setString(3, displayName); insertStatement.setString(4, body.id); @@ -119,7 +118,6 @@ public static void discordBotRegister(Context ctx) { 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), 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 index 52b05f8..9d1c39e 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java @@ -8,9 +8,8 @@ import com.mojang.serialization.codecs.RecordCodecBuilder; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.NaturalId; 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; @@ -133,8 +132,7 @@ public static void submitModrinth(Context ctx) { } if (projectId == null) { - long generatedProjectId = Project.ID_GENERATOR.next(); - projectId = Long.toString(generatedProjectId); + projectId = NaturalId.generateChecked("projects", "id"); projectInsertStatement.setString(1, projectId); projectInsertStatement.setString(2, slug); projectInsertStatement.setString(3, modrinthProject.id); @@ -147,8 +145,7 @@ public static void submitModrinth(Context ctx) { projectAuthorsStatement.execute(); } - long generatedSubmissionId = Submission.ID_GENERATOR.next(); - String submissionId = Long.toString(generatedSubmissionId); + String submissionId = NaturalId.generateChecked("submissions", "id"); submissionStatement.setString(1, submissionId); submissionStatement.setString(2, projectId); submissionStatement.setString(3, event.id()); From 685ae8a0d0c6dad927f0286902e2f213eb3b3e62 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sun, 28 Sep 2025 21:45:13 -0400 Subject: [PATCH 03/98] refactor: distinguish V1 methods from V2 & deduplicate code --- .../modgarden/backend/ModGardenBackend.java | 106 ++++++++++-------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 3c595ca..788e18b 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -54,9 +54,16 @@ public class ModGardenBackend { public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); - public static final String SAFE_URL_REGEX = "[a-zA-Z0-9!@$()`.+,_\"-]+"; + private static ModGardenBackend backend; + + private final Javalin app; + + 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); @@ -104,8 +111,9 @@ public static void main(String[] args) { Javalin app = Javalin.create(config -> config.jsonMapper(createDFUMapper())); app.get("", Landing::getLandingJson); + backend = new ModGardenBackend(app); - v1(app); + backend.v1(); app.error(400, BackendError::handleError); app.error(401, BackendError::handleError); @@ -117,65 +125,75 @@ public static void main(String[] args) { LOG.info("Mod Garden Backend Started!"); } - public static void v1(Javalin app) { - get(app, 1, "award/{award}", Award::getAwardType); + public void v1() { + get1("award/{award}", Award::getAwardType); + + get1("event/{event}", Event::getEvent); + get1("event/{event}/submissions", Submission::getSubmissionsByEvent); - get(app, 1, "event/{event}", Event::getEvent); - get(app, 1, "event/{event}/submissions", Submission::getSubmissionsByEvent); + get1("events", Event::getEvents); + get1("events/current/registration", Event::getCurrentRegistrationEvent); + get1("events/current/development", Event::getCurrentDevelopmentEvent); + get1("events/current/prefreeze", Event::getCurrentPreFreezeEvent); + get1("events/active", Event::getActiveEvents); - 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); + get1("mcaccount/{mcaccount}", MinecraftAccount::getAccount); - get(app, 1, "mcaccount/{mcaccount}", MinecraftAccount::getAccount); + get1("project/{project}", Project::getProject); - get(app, 1, "project/{project}", Project::getProject); + get1("submission/{submission}", Submission::getSubmission); - get(app, 1, "submission/{submission}", Submission::getSubmission); + get1("user/{user}", User::getUser); + get1("user/{user}/projects", Project::getProjectsByUser); + get1("user/{user}/submissions", Submission::getSubmissionsByUser); + get1("user/{user}/submissions/{event}", Submission::getSubmissionsByUserAndEvent); + get1("user/{user}/awards", Award::getAwardsByUser); - 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); + post1("discord/register", RegistrationHandler::discordBotRegister); - post(app, 1, "discord/register", RegistrationHandler::discordBotRegister); + get1("discord/oauth/modrinth", DiscordBotOAuthHandler::authModrinthAccount); + get1("discord/oauth/minecraft", DiscordBotOAuthHandler::authMinecraftAccount); + get1("discord/oauth/minecraft/challenge", DiscordBotOAuthHandler::getMicrosoftCodeChallenge); - 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); + post1("discord/submission/create/modrinth", DiscordBotSubmissionHandler::submitModrinth); + post1("discord/submission/modify/version/modrinth", DiscordBotSubmissionHandler::setVersionModrinth); + post1("discord/submission/delete", DiscordBotSubmissionHandler::unsubmit); - 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); + post1("discord/link", DiscordBotLinkHandler::link); + post1("discord/unlink", DiscordBotUnlinkHandler::unlink); - post(app, 1, "discord/link", DiscordBotLinkHandler::link); - post(app, 1, "discord/unlink", DiscordBotUnlinkHandler::unlink); + post1("discord/modify/username", DiscordBotProfileHandler::modifyUsername); + post1("discord/modify/displayname", DiscordBotProfileHandler::modifyDisplayName); + post1("discord/modify/pronouns", DiscordBotProfileHandler::modifyPronouns); + post1("discord/modify/avatar", DiscordBotProfileHandler::modifyAvatarUrl); - 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); + post1("discord/remove/pronouns", DiscordBotProfileHandler::removePronouns); + post1("discord/remove/avatar", DiscordBotProfileHandler::removeAvatarUrl); - post(app, 1, "discord/remove/pronouns", DiscordBotProfileHandler::removePronouns); - post(app, 1, "discord/remove/avatar", DiscordBotProfileHandler::removeAvatarUrl); + post1("discord/project/user/invite", DiscordBotTeamManagementHandler::sendInvite); + post1("discord/project/user/accept", DiscordBotTeamManagementHandler::acceptInvite); + post1("discord/project/user/decline", DiscordBotTeamManagementHandler::declineInvite); + post1("discord/project/user/remove", DiscordBotTeamManagementHandler::removeMember); + } + + private void get1(String endpoint, Handler consumer) { + this.app.get("/v1/" + endpoint, consumer); + } + + private void post1(String endpoint, Handler consumer) { + this.app.post("/v1/" + endpoint, consumer); + } - 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 get2(String endpoint, Handler consumer) { + this.app.get("/v2/" + endpoint, consumer); } - @SuppressWarnings("SameParameterValue") - private static void get(Javalin app, int version, String endpoint, Handler consumer) { - app.get("/v" + version + "/" + endpoint, consumer); + private void post2(String endpoint, Handler consumer) { + this.app.post("/v2/" + endpoint, consumer); } - @SuppressWarnings("SameParameterValue") - private static void post(Javalin app, int version, String endpoint, Handler consumer) { - app.post("/v" + version + "/" + endpoint, consumer); + private void put2(String endpoint, Handler consumer) { + this.app.put("/v2/" + endpoint, consumer); } public static Connection createDatabaseConnection() throws SQLException { From d626ac78aa5f5a7a1cb4f853ac32ace93f88694e Mon Sep 17 00:00:00 2001 From: sylv256 Date: Mon, 29 Sep 2025 14:45:58 -0400 Subject: [PATCH 04/98] refactor: v2 auth & v2 java-isms --- .../modgarden/backend/ModGardenBackend.java | 372 ++++++++++-------- .../backend/endpoint/AuthorizedEndpoint.java | 61 +++ .../modgarden/backend/endpoint/Endpoint.java | 30 ++ .../backend/endpoint/v2/AuthEndpoint.java | 16 + .../DiscordBotTeamManagementHandler.java | 3 +- .../net/modgarden/backend/util/AuthUtil.java | 19 +- 6 files changed, 323 insertions(+), 178 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/Endpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 788e18b..1b31ca1 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -9,6 +9,7 @@ import com.mojang.serialization.JsonOps; import io.github.cdimascio.dotenv.Dotenv; import io.javalin.Javalin; +import io.javalin.http.Context; import io.javalin.http.Handler; import io.javalin.json.JsonMapper; import net.modgarden.backend.data.BackendError; @@ -22,8 +23,10 @@ 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.endpoint.Endpoint; import net.modgarden.backend.handler.v1.discord.*; import net.modgarden.backend.handler.v1.RegistrationHandler; +import net.modgarden.backend.endpoint.v2.AuthEndpoint; import net.modgarden.backend.util.AuthUtil; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -46,15 +49,15 @@ 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(); - public static final String SAFE_URL_REGEX = "[a-zA-Z0-9!@$()`.+,_\"-]+"; + public static final String SAFE_URL_REGEX = "[a-zA-Z0-9!@$()`.+,_\"-]+"; private static ModGardenBackend backend; @@ -64,34 +67,34 @@ private ModGardenBackend(Javalin app) { this.app = app; } - public static void main(String[] args) { + public static void main(String[] args) { if ("development".equals(DOTENV.get("env"))) ((ch.qos.logback.classic.Logger)LOG).setLevel(Level.DEBUG); - try { + try { boolean createdFile = new File("./database.db").createNewFile(); - if (createdFile) { + if (createdFile) { createDatabaseContents(); updateSchemaVersion(); - LOG.debug("Successfully created database file."); - } + LOG.debug("Successfully created database file."); + } DatabaseFixer.createFixers(); DatabaseFixer.fixDatabase(); if (!createdFile) { updateSchemaVersion(); } - } catch (IOException ex) { - LOG.error("Failed to create database file.", ex); - } + } 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(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); @@ -106,7 +109,7 @@ public static void main(String[] args) { CODEC_REGISTRY.put(DiscordBotTeamManagementHandler.RemoveMemberBody.class, DiscordBotTeamManagementHandler.RemoveMemberBody.CODEC); Landing.createInstance(); - AuthUtil.clearTokensEachFifteenMinutes(); + AuthUtil.clearTokensEachFifteenMinutes(); DiscordBotTeamManagementHandler.clearInvitesEachDay(); Javalin app = Javalin.create(config -> config.jsonMapper(createDFUMapper())); @@ -115,15 +118,15 @@ public static void main(String[] args) { backend.v1(); - 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(404, BackendError::handleError); + app.error(422, BackendError::handleError); + app.error(500, BackendError::handleError); app.start(7070); LOG.info("Mod Garden Backend Started!"); - } + } public void v1() { get1("award/{award}", Award::getAwardType); @@ -176,6 +179,22 @@ public void v1() { post1("discord/project/user/remove", DiscordBotTeamManagementHandler::removeMember); } + public void v2() { + post2(new AuthEndpoint("generate_key") { + @Override + public void handle(@NotNull Context ctx) throws Exception { + super.handle(ctx); + + try ( + var connection = this.getDatabaseConnection(); + var statement = connection.prepareStatement("UPDATE credentials SET ") + ) { + String apiKey = AuthEndpoint.generateAPIKey(); + } + } + }); + } + private void get1(String endpoint, Handler consumer) { this.app.get("/v1/" + endpoint, consumer); } @@ -188,62 +207,67 @@ private void get2(String endpoint, Handler consumer) { this.app.get("/v2/" + endpoint, consumer); } - private void post2(String endpoint, Handler consumer) { - this.app.post("/v2/" + endpoint, consumer); + private void post2(Endpoint endpoint) { + this.app.post("/v2/" + endpoint.getPath(), endpoint); } private void put2(String endpoint, Handler consumer) { this.app.put("/v2/" + endpoint, consumer); } - 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)" + - ")"); + 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 PRIMARY KEY, + 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 PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + event_type_slug TEXT 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 PRIMARY KEY," + + "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," + @@ -251,50 +275,50 @@ private static void createDatabaseContents() { "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," + + statement.addBatch("CREATE TABLE IF NOT EXISTS submissions (" + + "id TEXT PRIMARY KEY," + "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," + + "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 PRIMARY KEY," + + "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," + + "PRIMARY KEY (id)" + + ")"); + statement.addBatch("CREATE TABLE IF NOT EXISTS award_instances (" + + "award_id TEXT PRIMARY KEY," + + "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 (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)" + - ")"); + "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," + @@ -305,68 +329,82 @@ private static void createDatabaseContents() { "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."); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_keys ( + uuid BLOB PRIMARY KEY, + salt TEXT NOT NULL, + hash TEXT NOT NULL, + expires INTEGER NOT NULL + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS credentials ( + user_id TEXT PRIMARY KEY, + api_key_uuid BLOB UNIQUE + ) + """); + 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, 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(); @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/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java new file mode 100644 index 0000000..3b687dc --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -0,0 +1,61 @@ +package net.modgarden.backend.endpoint; + +import io.javalin.http.Context; +import net.modgarden.backend.ModGardenBackend; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +public abstract class AuthorizedEndpoint extends Endpoint { + private static final String RANDOM_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_/+=;!@#$%^&*()"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + public AuthorizedEndpoint(String path) { + super(path); + } + + public static String generateRandomToken() { + return generateSecureRandomString(10); + } + + protected static String generateAPIKey() { + // we use 72 because it divides neatly with 3 (72/3 = 24) + return generateSecureRandomString(72); + } + + protected static String generateSecureRandomString(int length) { + byte[] bytes = new byte[length]; + SECURE_RANDOM.nextBytes(bytes); + return new String(Base64.getEncoder().encode(bytes), StandardCharsets.UTF_8); + } + + protected static String generateSalt(int length) { + if (length < 16) throw new IllegalArgumentException("A salt length < 16 is not strong enough!"); + return SECURE_RANDOM + .ints(length, 0, RANDOM_CHARS.length()) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + } + + @Override + public void handle(@NotNull Context ctx) throws Exception { + if (!validateAuth(ctx)) { + return; + } + + super.handle(ctx); + } + + private boolean validateAuth(Context ctx) { + boolean authorized = ("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals( + ctx.header("Authorization")); + if (!authorized) { + ctx.result("Unauthorized."); + ctx.status(401); + } + + return authorized; + } +} 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..a2da38d --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java @@ -0,0 +1,30 @@ +package net.modgarden.backend.endpoint; + +import io.javalin.http.Context; +import io.javalin.http.Handler; +import net.modgarden.backend.ModGardenBackend; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.SQLException; + +// witnesses would be *real* nice here. *sigh* +public abstract class Endpoint implements Handler { + private final String path; + + public Endpoint(String path) { + this.path = path; + } + + @Override + public void handle(@NotNull Context ctx) throws Exception { + } + + public String getPath() { + return path; + } + + protected Connection getDatabaseConnection() throws SQLException { + return ModGardenBackend.createDatabaseConnection(); + } +} 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..f6b1ef6 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java @@ -0,0 +1,16 @@ +package net.modgarden.backend.endpoint.v2; + +import io.javalin.http.Context; +import net.modgarden.backend.endpoint.AuthorizedEndpoint; +import org.jetbrains.annotations.NotNull; + +public abstract class AuthEndpoint extends AuthorizedEndpoint { + public AuthEndpoint(String path) { + super("auth/" + path); + } + + @Override + public void handle(@NotNull Context ctx) throws Exception { + super.handle(ctx); + } +} 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 index b8b0ff6..5bdf0db 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotTeamManagementHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotTeamManagementHandler.java @@ -4,6 +4,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.endpoint.AuthorizedEndpoint; import net.modgarden.backend.util.AuthUtil; import java.sql.Connection; @@ -97,7 +98,7 @@ public static void sendInvite(Context ctx) { ctx.status(201); return; } - var code = AuthUtil.generateRandomToken(); + var code = AuthorizedEndpoint.generateRandomToken(); var insertTeamInviteStatement = connection.prepareStatement( "INSERT INTO team_invites (code, project_id, user_id, expires, role) VALUES (?, ?, ?, ?, ?)"); insertTeamInviteStatement.setString(1, code); 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 } From b4c1eacf9bc56cdf59623f8ed45914e02b3a25d3 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Mon, 29 Sep 2025 23:06:38 -0400 Subject: [PATCH 05/98] feat: endpoint and datafixer --- .../modgarden/backend/ModGardenBackend.java | 207 +++++++++--------- .../backend/data/fixer/DatabaseFixer.java | 6 +- .../backend/data/fixer/fix/V5ToV6.java | 32 +++ .../endpoint/v2/auth/GenerateKeyEndpoint.java | 23 ++ 4 files changed, 165 insertions(+), 103 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 1b31ca1..97081c8 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.Context; import io.javalin.http.Handler; import io.javalin.json.JsonMapper; import net.modgarden.backend.data.BackendError; @@ -24,9 +23,9 @@ import net.modgarden.backend.data.profile.MinecraftAccount; import net.modgarden.backend.data.profile.User; import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; import net.modgarden.backend.handler.v1.discord.*; import net.modgarden.backend.handler.v1.RegistrationHandler; -import net.modgarden.backend.endpoint.v2.AuthEndpoint; import net.modgarden.backend.util.AuthUtil; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -45,6 +44,7 @@ import java.sql.Statement; import java.util.HashMap; import java.util.Map; +import java.util.function.Supplier; public class ModGardenBackend { public static final Dotenv DOTENV = Dotenv.load(); @@ -52,7 +52,7 @@ public class ModGardenBackend { 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; + public static final int DATABASE_SCHEMA_VERSION = 6; private static final Map> CODEC_REGISTRY = new HashMap<>(); public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); @@ -180,19 +180,7 @@ public void v1() { } public void v2() { - post2(new AuthEndpoint("generate_key") { - @Override - public void handle(@NotNull Context ctx) throws Exception { - super.handle(ctx); - - try ( - var connection = this.getDatabaseConnection(); - var statement = connection.prepareStatement("UPDATE credentials SET ") - ) { - String apiKey = AuthEndpoint.generateAPIKey(); - } - } - }); + post2(GenerateKeyEndpoint::new); } private void get1(String endpoint, Handler consumer) { @@ -203,16 +191,19 @@ private void post1(String endpoint, Handler consumer) { this.app.post("/v1/" + endpoint, consumer); } - private void get2(String endpoint, Handler consumer) { - this.app.get("/v2/" + endpoint, consumer); + private void get2(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); + this.app.get("/v2/" + endpoint.getPath(), endpoint); } - private void post2(Endpoint endpoint) { + private void post2(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); this.app.post("/v2/" + endpoint.getPath(), endpoint); } - private void put2(String endpoint, Handler consumer) { - this.app.put("/v2/" + endpoint, consumer); + private void put2(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); + this.app.put("/v2/" + endpoint.getPath(), endpoint); } public static Connection createDatabaseConnection() throws SQLException { @@ -225,7 +216,7 @@ private static void createDatabaseContents() { Statement statement = connection.createStatement()) { statement.addBatch(""" CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, + id TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL, display_name TEXT NOT NULL, pronouns TEXT, @@ -239,7 +230,7 @@ PRIMARY KEY(id) """); statement.addBatch(""" CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, + id TEXT UNIQUE NOT NULL, slug TEXT UNIQUE NOT NULL, event_type_slug TEXT NOT NULL, display_name TEXT NOT NULL, @@ -253,82 +244,100 @@ CREATE TABLE IF NOT EXISTS events ( PRIMARY KEY (id) ) """); - statement.addBatch("CREATE TABLE IF NOT EXISTS projects (" + - "id TEXT PRIMARY KEY," + - "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 PRIMARY KEY," + - "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 PRIMARY KEY," + - "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 PRIMARY KEY," + - "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.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 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), + 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.addBatch(""" CREATE TABLE IF NOT EXISTS api_keys ( uuid BLOB PRIMARY KEY, 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..4e61339 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java @@ -2,10 +2,7 @@ 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; @@ -20,6 +17,7 @@ public static void createFixers() { FIXES.add(new V2ToV3()); FIXES.add(new V3ToV4()); FIXES.add(new V4ToV5()); + FIXES.add(new V5ToV6()); } public static void fixDatabase() { 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..970c42e --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -0,0 +1,32 @@ +package net.modgarden.backend.data.fixer.fix; + +import net.modgarden.backend.data.fixer.DatabaseFix; + +import java.sql.Connection; +import java.sql.SQLException; + +public class V5ToV6 extends DatabaseFix { + public V5ToV6() { + super(5); + } + + @Override + public void fix(Connection connection) throws SQLException { + var statement = connection.createStatement(); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_keys ( + uuid BLOB PRIMARY KEY, + salt TEXT NOT NULL, + hash TEXT NOT NULL, + expires INTEGER NOT NULL + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS credentials ( + user_id TEXT PRIMARY KEY, + api_key_uuid BLOB UNIQUE + ) + """); + statement.executeBatch(); + } +} 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..49383bb --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -0,0 +1,23 @@ +package net.modgarden.backend.endpoint.v2.auth; + +import io.javalin.http.Context; +import net.modgarden.backend.endpoint.v2.AuthEndpoint; +import org.jetbrains.annotations.NotNull; + +public class GenerateKeyEndpoint extends AuthEndpoint { + public GenerateKeyEndpoint() { + super("generate_key"); + } + + @Override + public void handle(@NotNull Context ctx) throws Exception { + super.handle(ctx); + + try ( + var connection = this.getDatabaseConnection(); + var statement = connection.prepareStatement("UPDATE credentials SET ") + ) { + String apiKey = AuthEndpoint.generateAPIKey(); + } + } +} From 9a0a752f74ed87eff3b27209e74bc807a62a4f16 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Mon, 29 Sep 2025 23:08:05 -0400 Subject: [PATCH 06/98] cleanup: use Collections.addAll instead of FIXES.add every damn time --- .../backend/data/fixer/DatabaseFixer.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 4e61339..a364be0 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java @@ -7,17 +7,21 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.Collections; import java.util.List; 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()); - FIXES.add(new V5ToV6()); + Collections.addAll( + FIXES, + new V1ToV2(), + new V2ToV3(), + new V3ToV4(), + new V4ToV5(), + new V5ToV6() + ); } public static void fixDatabase() { From 5748bcccd320f988daf7a362f4f5a90c8b2c4890 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Mon, 29 Sep 2025 23:21:10 -0400 Subject: [PATCH 07/98] build: build in java major version twenty-five --- build.gradle.kts | 38 +++++++++++------------- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index bda2e0b..45be11d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,13 +1,10 @@ -import org.jetbrains.gradle.ext.Application -import org.jetbrains.gradle.ext.runConfigurations -import org.jetbrains.gradle.ext.settings - plugins { application java idea `java-library-distribution` - alias(libs.plugins.idea.ext) apply true + // doesn't work :( +// alias(libs.plugins.idea.ext) apply true } group = "net.modgarden" @@ -81,18 +78,19 @@ application { mainClass = "net.modgarden.backend.ModGardenBackend" } -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" - ) - } - } - } -} +// fixme wake me up when september ends (when idea_ext is fixed) +//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" +// ) +// } +// } +// } +//} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e935d12..5d9fa41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,4 +25,4 @@ base62 = { group = "io.seruco.encoding", name = "base62", version.ref = "base62" jetbrains_annotations = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains_annotations" } [plugins] -idea_ext = { id = "org.jetbrains.gradle.plugin.idea-ext", version.ref = "idea_ext" } +#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 From 7c018e18d04bbefea55c2e63c6ed2c062640183a Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 30 Sep 2025 00:18:56 -0400 Subject: [PATCH 08/98] feat(db): implement passwords table & use foreign keys --- .../modgarden/backend/ModGardenBackend.java | 20 ++++++++++++------- .../backend/data/fixer/fix/V5ToV6.java | 20 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 97081c8..8174c5a 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -340,16 +340,22 @@ PRIMARY KEY (code) """); statement.addBatch(""" CREATE TABLE IF NOT EXISTS api_keys ( - uuid BLOB PRIMARY KEY, - salt TEXT NOT NULL, - hash TEXT NOT NULL, - expires INTEGER NOT NULL + user_id TEXT NOT NULL, + salt BLOB NOT NULL, + hash BLOB UNIQUE NOT NULL, + expires INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_id) ) """); statement.addBatch(""" - CREATE TABLE IF NOT EXISTS credentials ( - user_id TEXT PRIMARY KEY, - api_key_uuid BLOB UNIQUE + CREATE TABLE IF NOT EXISTS passwords ( + user_id TEXT NOT NULL, + salt BLOB NOT NULL, + hash BLOB NOT NULL, + last_updated INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_id) ) """); statement.executeBatch(); 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 index 970c42e..3dbc97a 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -15,16 +15,22 @@ public void fix(Connection connection) throws SQLException { var statement = connection.createStatement(); statement.addBatch(""" CREATE TABLE IF NOT EXISTS api_keys ( - uuid BLOB PRIMARY KEY, - salt TEXT NOT NULL, - hash TEXT NOT NULL, - expires INTEGER NOT NULL + user_id TEXT NOT NULL, + salt BLOB NOT NULL, + hash BLOB UNIQUE NOT NULL, + expires INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_id) ) """); statement.addBatch(""" - CREATE TABLE IF NOT EXISTS credentials ( - user_id TEXT PRIMARY KEY, - api_key_uuid BLOB UNIQUE + CREATE TABLE IF NOT EXISTS passwords ( + user_id TEXT NOT NULL, + salt BLOB NOT NULL, + hash BLOB NOT NULL, + last_updated INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_id) ) """); statement.executeBatch(); From 31a46d37239deca5e8924e7af29ecd219de1519c Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 30 Sep 2025 02:30:03 -0400 Subject: [PATCH 09/98] feat: API keys --- build.gradle.kts | 2 + gradle/libs.versions.toml | 6 +- .../backend/endpoint/AuthorizedEndpoint.java | 61 ++++++++++++++++--- .../backend/endpoint/v2/AuthEndpoint.java | 4 +- .../endpoint/v2/auth/GenerateKeyEndpoint.java | 16 ++++- 5 files changed, 73 insertions(+), 16 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 45be11d..d6709fe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,8 @@ dependencies { implementation(libs.jwt.gson) implementation(libs.base62) implementation(libs.jetbrains.annotations) + + implementation(libs.argon2.jvm) } tasks { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d9fa41..64426c3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,9 @@ 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.1.9" [libraries] dfu = { group = "com.mojang", name = "datafixerupper", version.ref = "dfu" } @@ -24,5 +26,7 @@ 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/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 3b687dc..ca7222b 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -1,5 +1,8 @@ package net.modgarden.backend.endpoint; +import de.mkammerer.argon2.Argon2Advanced; +import de.mkammerer.argon2.Argon2Factory; +import de.mkammerer.argon2.Argon2Version; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; import org.jetbrains.annotations.NotNull; @@ -9,43 +12,79 @@ import java.util.Base64; public abstract class AuthorizedEndpoint extends Endpoint { - private static final String RANDOM_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_/+=;!@#$%^&*()"; 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 static final Argon2Version ARGON_2_VERSION = Argon2Version.V13; public AuthorizedEndpoint(String path) { super(path); } public static String generateRandomToken() { - return generateSecureRandomString(10); + return generateSecretString(10); } protected static String generateAPIKey() { // we use 72 because it divides neatly with 3 (72/3 = 24) - return generateSecureRandomString(72); + return generateSecretString(72); } - protected static String generateSecureRandomString(int length) { + /// Generate a secret (e.g. password) in [String] form. + protected static String generateSecretString(int length) { byte[] bytes = new byte[length]; SECURE_RANDOM.nextBytes(bytes); return new String(Base64.getEncoder().encode(bytes), StandardCharsets.UTF_8); } - protected static String generateSalt(int length) { + /// Generate a salted hash for a secret (e.g. password). + protected static HashedSecret hashSecret(byte[] bytes) { + byte[] salt = generateSalt(16); + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id + byte[] hash = ARGON.rawHash( + 3, + 12288, + 1, + bytes, + salt + ); + return new HashedSecret(salt, hash); + } + + /// Verify that a secret (e.g. password) matches the given salt and hash. + protected static boolean verifySecret(HashedSecret hashedSecret, byte[] secret) { + return ARGON.verifyAdvanced( + 3, + 12228, + 1, + secret, + hashedSecret.salt(), + null, + null, + hashedSecret.hash().length, + ARGON_2_VERSION, + hashedSecret.hash() + ); + } + + protected static byte[] generateSalt(int length) { if (length < 16) throw new IllegalArgumentException("A salt length < 16 is not strong enough!"); - return SECURE_RANDOM - .ints(length, 0, RANDOM_CHARS.length()) - .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) - .toString(); + byte[] bytes = new byte[length]; + SECURE_RANDOM.nextBytes(bytes); + return bytes; } + protected abstract void handle(@NotNull Context ctx, String userId) throws Exception; + @Override - public void handle(@NotNull Context ctx) throws Exception { + public final void handle(@NotNull Context ctx) throws Exception { if (!validateAuth(ctx)) { return; } super.handle(ctx); + this.handle(ctx, "grbot"); // todo un-hardcode when we make proper user_id:password auth } private boolean validateAuth(Context ctx) { @@ -58,4 +97,6 @@ private boolean validateAuth(Context ctx) { return authorized; } + + public record HashedSecret(byte[] salt, byte[] hash) {} } diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java index f6b1ef6..aa36917 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java @@ -10,7 +10,5 @@ public AuthEndpoint(String path) { } @Override - public void handle(@NotNull Context ctx) throws Exception { - super.handle(ctx); - } + public abstract void handle(@NotNull Context ctx, String userId) throws Exception; } 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 index 49383bb..4ac55aa 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -4,20 +4,32 @@ import net.modgarden.backend.endpoint.v2.AuthEndpoint; import org.jetbrains.annotations.NotNull; +import javax.sql.rowset.serial.SerialBlob; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; + public class GenerateKeyEndpoint extends AuthEndpoint { public GenerateKeyEndpoint() { super("generate_key"); } @Override - public void handle(@NotNull Context ctx) throws Exception { + public void handle(@NotNull Context ctx, String userId) throws Exception { super.handle(ctx); try ( var connection = this.getDatabaseConnection(); - var statement = connection.prepareStatement("UPDATE credentials SET ") + var apiKeyStatement = connection.prepareStatement("INSERT INTO api_keys(user_id, salt, hash, expires) VALUES (?, ?, ?, ?)") ) { String apiKey = AuthEndpoint.generateAPIKey(); + HashedSecret hashedSecret = + AuthEndpoint.hashSecret(apiKey.getBytes(StandardCharsets.UTF_8)); + apiKeyStatement.setString(1, userId); + apiKeyStatement.setBlob(2, new SerialBlob(hashedSecret.salt())); + apiKeyStatement.setBlob(3, new SerialBlob(hashedSecret.hash())); + apiKeyStatement.setLong(4, Instant.now().plus(Duration.ofDays(365)).getEpochSecond()); + apiKeyStatement.execute(); } } } From 2c4c3f75e13f6a7099bf7e853b5bf106524cd679 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 30 Sep 2025 12:15:33 -0400 Subject: [PATCH 10/98] fix: NIDs allowed only when they existed --- src/main/java/net/modgarden/backend/data/NaturalId.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 91841fb..5c031f8 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -11,6 +11,7 @@ public final class NaturalId { private static final Pattern PATTERN = Pattern.compile("[a-z]{5}"); private static final Pattern PATTERN_LEGACY = Pattern.compile("[0-9]+"); + private static final Pattern DISALLOWED_PATTERN = Pattern.compile("((z{3}.*)|(.*bot)|(.*acc))"); private static final String alphabet = "abcdefghijklmnopqrstuvwxyz"; private NaturalId() {} @@ -41,7 +42,7 @@ public static String generateChecked(String table, String key) throws SQLExcepti exists.setString(1, table); exists.setString(2, key); exists.setString(3, naturalId); - if (exists.execute()) { + if (!exists.execute()) { id = naturalId; } } From 2b9e929c2b76ea3f7a59e2e9024ba0012db1fc3a Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 30 Sep 2025 12:36:39 -0400 Subject: [PATCH 11/98] feat: disallow reserved user IDs --- .../net/modgarden/backend/data/NaturalId.java | 19 ++++++++++++++----- .../handler/v1/RegistrationHandler.java | 2 +- .../discord/DiscordBotSubmissionHandler.java | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 5c031f8..fae2a60 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -11,11 +11,20 @@ public final class NaturalId { private static final Pattern PATTERN = Pattern.compile("[a-z]{5}"); private static final Pattern PATTERN_LEGACY = Pattern.compile("[0-9]+"); - private static final Pattern DISALLOWED_PATTERN = Pattern.compile("((z{3}.*)|(.*bot)|(.*acc))"); + // 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 NaturalId() {} + public static boolean isReserved(String id) { + return RESERVED_PATTERN.matcher(id).hasMatch(); + } + public static boolean isValid(String id) { return PATTERN.matcher(id).hasMatch(); } @@ -24,7 +33,7 @@ public static boolean isValidLegacy(String id) { return isValid(id) || PATTERN_LEGACY.matcher(id).hasMatch(); } - public static String of(RandomGenerator random) { + private static String generateUnchecked(RandomGenerator random) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < 5; i++) { builder.append(alphabet.charAt(random.nextInt(alphabet.length()))); @@ -33,16 +42,16 @@ public static String of(RandomGenerator random) { } @NotNull - public static String generateChecked(String table, String key) throws SQLException { + public static String generate(String table, String key) throws SQLException { String id = null; try (Connection connection1 = ModGardenBackend.createDatabaseConnection()) { while (id == null) { - String naturalId = of(RandomGenerator.getDefault()); + String naturalId = generateUnchecked(RandomGenerator.getDefault()); var exists = connection1.prepareStatement("SELECT true FROM ? WHERE ? = ?"); exists.setString(1, table); exists.setString(2, key); exists.setString(3, naturalId); - if (!exists.execute()) { + if (!exists.execute() && !isReserved(naturalId)) { id = naturalId; } } diff --git a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java index 5c36a1e..7a05150 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java @@ -100,7 +100,7 @@ public static void discordBotRegister(Context ctx) { return; } - insertStatement.setString(1, NaturalId.generateChecked("users", "id")); + insertStatement.setString(1, NaturalId.generate("users", "id")); insertStatement.setString(2, username); insertStatement.setString(3, displayName); insertStatement.setString(4, body.id); 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 index 9d1c39e..6d8378d 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java @@ -132,7 +132,7 @@ public static void submitModrinth(Context ctx) { } if (projectId == null) { - projectId = NaturalId.generateChecked("projects", "id"); + projectId = NaturalId.generate("projects", "id"); projectInsertStatement.setString(1, projectId); projectInsertStatement.setString(2, slug); projectInsertStatement.setString(3, modrinthProject.id); @@ -145,7 +145,7 @@ public static void submitModrinth(Context ctx) { projectAuthorsStatement.execute(); } - String submissionId = NaturalId.generateChecked("submissions", "id"); + String submissionId = NaturalId.generate("submissions", "id"); submissionStatement.setString(1, submissionId); submissionStatement.setString(2, projectId); submissionStatement.setString(3, event.id()); From 0396e01bf520612e8fb73e0b335a7d6367444f56 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 30 Sep 2025 12:45:06 -0400 Subject: [PATCH 12/98] fix: check start and end for NID validation --- src/main/java/net/modgarden/backend/data/NaturalId.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index fae2a60..8853e12 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -9,7 +9,7 @@ import java.util.regex.Pattern; public final class NaturalId { - private static final Pattern PATTERN = Pattern.compile("[a-z]{5}"); + private static final Pattern PATTERN = Pattern.compile("^[a-z]{5}$"); private static final Pattern PATTERN_LEGACY = Pattern.compile("[0-9]+"); // 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 From ae3acd3be68a2999896bbe9d98f56a90106f9636 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 1 Oct 2025 15:57:57 -0400 Subject: [PATCH 13/98] refactor: reformat database to fit the v2 spec --- .../modgarden/backend/ModGardenBackend.java | 67 +- .../backend/data/DevelopmentModeData.java | 655 +++++++++--------- .../modgarden/backend/data/award/Award.java | 3 +- .../modgarden/backend/data/event/Project.java | 34 +- .../backend/data/fixer/DatabaseFix.java | 15 +- .../backend/data/fixer/DatabaseFixer.java | 17 +- .../backend/data/fixer/fix/V1ToV2.java | 5 +- .../backend/data/fixer/fix/V2ToV3.java | 5 +- .../backend/data/fixer/fix/V3ToV4.java | 5 +- .../backend/data/fixer/fix/V4ToV5.java | 5 +- .../backend/data/fixer/fix/V5ToV6.java | 135 +++- 11 files changed, 563 insertions(+), 383 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 8174c5a..84ddd08 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -221,8 +221,6 @@ CREATE TABLE IF NOT EXISTS users ( 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) @@ -248,36 +246,28 @@ PRIMARY KEY (id) 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 ( + CREATE TABLE IF NOT EXISTS project_roles ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, + permissions INTEGER NOT NULL, + role_name TEXT NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id), - FOREIGN KEY (user_id) REFERENCES users(id), - PRIMARY KEY (project_id, user_id) + FOREIGN KEY (user_id) REFERENCES users(id) ) """); + // This ensures that users cannot be listed twice on the same project 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) - ) + 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, - modrinth_version_id TEXT NOT NULL, submitted INTEGER NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id), FOREIGN KEY (event) REFERENCES events(id), @@ -285,6 +275,15 @@ 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), + PRIMARY KEY (submission_id) + ) + """); + statement.addBatch(""" CREATE TABLE IF NOT EXISTS minecraft_accounts ( uuid TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, @@ -300,8 +299,8 @@ CREATE TABLE IF NOT EXISTS awards ( 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) + tier TEXT NOT NULL CHECK (tier in ('COMMON', 'UNCOMMON', 'RARE', 'LEGENDARY')), + PRIMARY KEY (id) ) """); statement.addBatch(""" @@ -340,12 +339,24 @@ PRIMARY KEY (code) """); statement.addBatch(""" CREATE TABLE IF NOT EXISTS api_keys ( + uuid BLOB NOT NULL, user_id TEXT NOT NULL, salt BLOB NOT NULL, - hash BLOB UNIQUE NOT NULL, + hash BLOB NOT NULL, expires INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id), - PRIMARY KEY (user_id) + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_key_scopes ( + uuid BLOB NOT NULL, + scope TEXT CHECK (scope in ('PROJECT', 'USER')), + project_id TEXT, + permissions INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (uuid) REFERENCES api_keys(uuid), + PRIMARY KEY (uuid) ) """); statement.addBatch(""" @@ -358,6 +369,22 @@ FOREIGN KEY (user_id) REFERENCES users(id), PRIMARY KEY (user_id) ) """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS integration_modrinth ( + user_id TEXT NOT NULL, + modrinth_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS integration_discord ( + user_id TEXT NOT NULL, + discord_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_id) + ) + """); statement.executeBatch(); } catch (SQLException ex) { LOG.error("Failed to create database tables. ", ex); 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/award/Award.java b/src/main/java/net/modgarden/backend/data/award/Award.java index fc4030b..3e189b5 100644 --- a/src/main/java/net/modgarden/backend/data/award/Award.java +++ b/src/main/java/net/modgarden/backend/data/award/Award.java @@ -83,8 +83,7 @@ public static void getAwardsByUser(Context ctx) { 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/event/Project.java b/src/main/java/net/modgarden/backend/data/event/Project.java index 2f5cd59..4b0f3eb 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -20,15 +20,11 @@ // 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 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) ).apply(inst, Project::new))); @@ -74,8 +70,6 @@ public static Project queryFromSlug(String slug) { return new Project( result.getString("id"), result.getString("slug"), - result.getString("modrinth_id"), - result.getString("attributed_to"), authors, builders ); @@ -101,8 +95,6 @@ public static Project queryFromId(String id) { return new Project( result.getString("id"), result.getString("slug"), - result.getString("modrinth_id"), - result.getString("attributed_to"), authors, builders ); @@ -138,8 +130,6 @@ public static void getProjectsByUser(Context ctx) { } 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); @@ -155,8 +145,6 @@ private static String selectById() { 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 @@ -168,9 +156,7 @@ private static String selectById() { p.id = ? GROUP BY p.id, - p.slug, - p.modrinth_id, - p.attributed_to + p.slug """; } @@ -179,8 +165,6 @@ private static String selectBySlug() { 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 @@ -192,9 +176,7 @@ private static String selectBySlug() { p.slug = ? GROUP BY p.id, - p.slug, - p.modrinth_id, - p.attributed_to + p.slug """; } @@ -202,8 +184,6 @@ 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 @@ -220,9 +200,7 @@ WHERE p.id IN (SELECT pa.project_id WHERE uu.id = ? OR uu.username = ?) GROUP BY p.id, - p.slug, - p.modrinth_id, - p.attributed_to + p.slug """; } @@ -230,8 +208,6 @@ 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 @@ -247,9 +223,7 @@ private static String selectAllByEvent() { ON e.id = s.event WHERE e.id = ? or e.slug = ? GROUP BY p.id, - p.slug, - p.modrinth_id, - p.attributed_to + p.slug """; } 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..c7587df 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,15 @@ 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); } } 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 a364be0..93ea5db 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java @@ -7,8 +7,11 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.Consumer; public class DatabaseFixer { private static final List FIXES = new ObjectArrayList<>(); @@ -25,6 +28,7 @@ public static void createFixers() { } public static void fixDatabase() { + List> postFixers = new ArrayList<>(); try (Connection connection = ModGardenBackend.createDatabaseConnection(); PreparedStatement schemaVersion = connection.prepareStatement("SELECT version FROM schema")) { ResultSet query = schemaVersion.executeQuery(); @@ -34,10 +38,21 @@ public static void fixDatabase() { return; for (DatabaseFix fix : FIXES) { - fix.fixInternal(connection, version); + var postFixer = fix.fixInternal(connection, version); + if (postFixer != null) { + postFixers.add(postFixer); + } } } catch (Exception ex) { ModGardenBackend.LOG.error("Failed to fix data: ", ex); } + + for (var postFixer : postFixers) { + try (Connection connection = ModGardenBackend.createDatabaseConnection()) { + postFixer.accept(connection); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } } } 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 index 3dbc97a..4c95845 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.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 V5ToV6 extends DatabaseFix { public V5ToV6() { @@ -11,16 +13,28 @@ public V5ToV6() { } @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 api_keys ( + uuid BLOB NOT NULL, user_id TEXT NOT NULL, salt BLOB NOT NULL, hash BLOB UNIQUE NOT NULL, expires INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id), - PRIMARY KEY (user_id) + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_key_scopes ( + uuid BLOB NOT NULL, + scope TEXT CHECK (scope in ('project', 'user')), + project_id TEXT, + permissions INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (uuid) REFERENCES api_keys(uuid), + PRIMARY KEY (uuid) ) """); statement.addBatch(""" @@ -33,6 +47,123 @@ FOREIGN KEY (user_id) REFERENCES users(id), PRIMARY KEY (user_id) ) """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS integration_modrinth ( + user_id TEXT NOT NULL, + modrinth_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_id) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS integration_discord ( + user_id TEXT NOT NULL, + discord_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (user_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), + PRIMARY KEY (submission_id) + ) + """); + + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_roles ( + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + permissions INTEGER NOT NULL, + role_name TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """); + + statement.addBatch(""" + CREATE UNIQUE INDEX idx_project_roles_two_ids ON project_roles(project_id, user_id) + """); + statement.executeBatch(); + + statement.execute(""" + INSERT INTO integration_modrinth (user_id, modrinth_id) + SELECT id, modrinth_id FROM users + WHERE modrinth_id NOT NULL + """); + + statement.execute(""" + INSERT INTO integration_discord (user_id, discord_id) + SELECT id, discord_id FROM users + """); + + statement.execute("ALTER TABLE users RENAME TO users_old"); + statement.execute(""" + CREATE TABLE users AS SELECT id, id, username, display_name, pronouns, avatar_url, created, permissions FROM users_old + """); + + + statement.execute("CREATE TABLE submissions_mr AS SELECT * FROM submissions"); + statement.execute("ALTER TABLE submissions_mr ADD COLUMN modrinth_id TEXT"); + + statement.execute(""" + UPDATE submissions_mr + SET modrinth_id = ( + SELECT modrinth_id FROM projects WHERE submissions_mr.project_id = projects.id + ) + """); + + statement.execute("ALTER TABLE submissions RENAME TO submissions_old"); + statement.execute(""" + CREATE TABLE submissions AS SELECT * FROM submissions_old + WHERE NOT modrinth_version_id + """); + + statement.execute("ALTER TABLE projects RENAME TO projects_old"); + statement.execute(""" + CREATE TABLE projects AS SELECT * FROM projects_old + WHERE NOT modrinth_id + """); + + statement.execute(""" + 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 TEMP TABLE IF NOT EXISTS project_roles ( + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + permissions INTEGER, + role_name TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """); + + statement.execute(""" + INSERT INTO temp.project_roles (project_id, user_id) + SELECT project_id, user_id FROM project_authors + """); + + return connection2 -> { + try { + var statement2 = connection2.createStatement(); + statement2.execute("DROP TABLE users_old"); + statement2.execute("DROP TABLE submissions_old"); + statement2.execute("DROP TABLE submissions_mr"); + statement2.execute("ALTER TABLE submissions DROP COLUMN modrinth_version_id"); + statement2.execute("DROP TABLE project_builders"); + statement2.execute("DROP TABLE project_authors"); + statement2.execute("DROP TABLE temp.project_roles"); + } catch (SQLException e) { + throw new RuntimeException(e); + } + }; } } From a86f5cfc759b90491ea97ccd0a4035c9c5743c05 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 1 Oct 2025 16:13:42 -0400 Subject: [PATCH 14/98] refactor: move away from a constant schema value --- .../net/modgarden/backend/ModGardenBackend.java | 5 ++--- .../modgarden/backend/data/fixer/DatabaseFix.java | 4 ++++ .../modgarden/backend/data/fixer/DatabaseFixer.java | 13 ++++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 84ddd08..7c610e4 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -52,7 +52,6 @@ public class ModGardenBackend { 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 = 6; private static final Map> CODEC_REGISTRY = new HashMap<>(); public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); @@ -73,12 +72,12 @@ public static void main(String[] args) { try { boolean createdFile = new File("./database.db").createNewFile(); + DatabaseFixer.createFixers(); if (createdFile) { createDatabaseContents(); updateSchemaVersion(); LOG.debug("Successfully created database file."); } - DatabaseFixer.createFixers(); DatabaseFixer.fixDatabase(); if (!createdFile) { updateSchemaVersion(); @@ -404,7 +403,7 @@ private static void updateSchemaVersion() { statement.addBatch("DELETE FROM schema"); statement.executeBatch(); try (PreparedStatement prepared = connection.prepareStatement("INSERT INTO schema VALUES (?)")) { - prepared.setInt(1, DATABASE_SCHEMA_VERSION); + prepared.setInt(1, DatabaseFixer.getSchemaVersion()); prepared.execute(); } } catch (SQLException ex) { 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 c7587df..b14ee8a 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFix.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFix.java @@ -24,4 +24,8 @@ protected Consumer fixInternal(Connection connection, int currentSch 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 93ea5db..10e83de 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java @@ -27,6 +27,13 @@ public static void createFixers() { ); } + public static int getSchemaVersion() { + if (FIXES.isEmpty()) { + return -1; + } + return FIXES.getLast().getVersionToFixFrom() + 1; + } + public static void fixDatabase() { List> postFixers = new ArrayList<>(); try (Connection connection = ModGardenBackend.createDatabaseConnection(); @@ -34,7 +41,11 @@ public static void fixDatabase() { ResultSet query = schemaVersion.executeQuery(); int version = query.getInt(1); - if (version >= ModGardenBackend.DATABASE_SCHEMA_VERSION) + 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) { From 8d1d1b6f9c1af1bc305dc35ec36aead8df57a762 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 1 Oct 2025 17:13:06 -0400 Subject: [PATCH 15/98] refactor: finish project data fixer --- .../backend/data/fixer/fix/V5ToV6.java | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) 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 index 4c95845..336be7b 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -77,8 +77,8 @@ PRIMARY KEY (submission_id) CREATE TABLE IF NOT EXISTS project_roles ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, - permissions INTEGER NOT NULL, - role_name TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + role_name TEXT NOT NULL DEFAULT 'Member', FOREIGN KEY (project_id) REFERENCES projects(id), FOREIGN KEY (user_id) REFERENCES users(id) ) @@ -135,32 +135,38 @@ INSERT INTO submission_type_modrinth (submission_id, modrinth_id, version_id) WHERE modrinth_id NOT NULL """); - statement.addBatch(""" - CREATE TEMP TABLE IF NOT EXISTS project_roles ( + statement.execute(""" + CREATE TABLE IF NOT EXISTS project_roles_temp ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, - permissions INTEGER, - role_name TEXT, + permissions INTEGER NOT NULL DEFAULT 1, + role_name TEXT NOT NULL DEFAULT 'Member', FOREIGN KEY (project_id) REFERENCES projects(id), FOREIGN KEY (user_id) REFERENCES users(id) ) """); statement.execute(""" - INSERT INTO temp.project_roles (project_id, user_id) + INSERT OR REPLACE INTO project_roles_temp (project_id, user_id) SELECT project_id, user_id FROM project_authors """); - return connection2 -> { + statement.execute(""" + INSERT INTO project_roles (project_id, user_id, permissions, role_name) + SELECT project_id, user_id, permissions, role_name FROM project_roles_temp + """); + + return dropConnection -> { try { - var statement2 = connection2.createStatement(); - statement2.execute("DROP TABLE users_old"); - statement2.execute("DROP TABLE submissions_old"); - statement2.execute("DROP TABLE submissions_mr"); - statement2.execute("ALTER TABLE submissions DROP COLUMN modrinth_version_id"); - statement2.execute("DROP TABLE project_builders"); - statement2.execute("DROP TABLE project_authors"); - statement2.execute("DROP TABLE temp.project_roles"); + var dropStatement = dropConnection.createStatement(); + dropStatement.execute("DROP TABLE users_old"); + dropStatement.execute("DROP TABLE submissions_old"); + dropStatement.execute("DROP TABLE submissions_mr"); + dropStatement.execute("ALTER TABLE submissions DROP COLUMN modrinth_version_id"); + dropStatement.execute("DROP TABLE projects_old"); + dropStatement.execute("DROP TABLE project_builders"); + dropStatement.execute("DROP TABLE project_authors"); + dropStatement.execute("DROP TABLE project_roles_temp"); } catch (SQLException e) { throw new RuntimeException(e); } From 7cb52436f451c0f0f59241a8236e983f1262a24d Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 1 Oct 2025 17:20:32 -0400 Subject: [PATCH 16/98] fix: set defaults for project_roles table --- src/main/java/net/modgarden/backend/ModGardenBackend.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 7c610e4..c3194bb 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -252,8 +252,8 @@ PRIMARY KEY (id) CREATE TABLE IF NOT EXISTS project_roles ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, - permissions INTEGER NOT NULL, - role_name TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + role_name TEXT NOT NULL DEFAULT 'Member', FOREIGN KEY (project_id) REFERENCES projects(id), FOREIGN KEY (user_id) REFERENCES users(id) ) From 109109556ccaeaa9de339f2865894d22c4ed9b3d Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 1 Oct 2025 17:30:49 -0400 Subject: [PATCH 17/98] fix: ensure droppers are executed after each fixer --- .../backend/data/fixer/DatabaseFixer.java | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) 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 10e83de..875242e 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java @@ -8,7 +8,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.function.Consumer; @@ -35,12 +34,12 @@ public static int getSchemaVersion() { } public static void fixDatabase() { - List> postFixers = new ArrayList<>(); + 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); + 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"); @@ -48,21 +47,24 @@ public static void fixDatabase() { if (version == lastVersion) return; - for (DatabaseFix fix : FIXES) { - var postFixer = fix.fixInternal(connection, version); - if (postFixer != null) { - postFixers.add(postFixer); - } - } } catch (Exception ex) { ModGardenBackend.LOG.error("Failed to fix data: ", ex); } - for (var postFixer : postFixers) { + 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()) { - postFixer.accept(connection); - } catch (SQLException e) { - throw new RuntimeException(e); + if (dropper != null) { + dropper.accept(connection); + } + } catch (SQLException ex) { + ModGardenBackend.LOG.error("Failed to fix data: ", ex); } } } From 8c7982b8274f5c7f3ec6bec3db42e2ba31ef37f5 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Thu, 2 Oct 2025 12:12:59 -0400 Subject: [PATCH 18/98] refactor: datafix user IDs & fix db modification order to prevent foreign key issues --- .../modgarden/backend/ModGardenBackend.java | 57 +++-- .../net/modgarden/backend/data/NaturalId.java | 41 +++- .../backend/data/fixer/fix/V5ToV6.java | 207 ++++++++++++++---- .../handler/v1/RegistrationHandler.java | 2 +- .../discord/DiscordBotSubmissionHandler.java | 6 +- 5 files changed, 241 insertions(+), 72 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index c3194bb..b3d3eff 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -14,6 +14,7 @@ import net.modgarden.backend.data.BackendError; import net.modgarden.backend.data.DevelopmentModeData; import net.modgarden.backend.data.Landing; +import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.award.Award; import net.modgarden.backend.data.award.AwardInstance; import net.modgarden.backend.data.event.Event; @@ -30,6 +31,7 @@ import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.sqlite.Function; import java.io.File; import java.io.IOException; @@ -254,8 +256,8 @@ CREATE TABLE IF NOT EXISTS project_roles ( 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), - FOREIGN KEY (user_id) REFERENCES users(id) + 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 @@ -268,8 +270,8 @@ CREATE TABLE IF NOT EXISTS submissions ( event TEXT NOT NULL, project_id TEXT NOT NULL, submitted INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES projects(id), - FOREIGN KEY (event) REFERENCES events(id), + 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) ) """); @@ -278,7 +280,7 @@ 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), + FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (submission_id) ) """); @@ -286,7 +288,7 @@ PRIMARY KEY (submission_id) CREATE TABLE IF NOT EXISTS minecraft_accounts ( uuid TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (uuid) ) """); @@ -309,9 +311,9 @@ CREATE TABLE IF NOT EXISTS award_instances ( 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), + 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) ) """); @@ -331,8 +333,8 @@ CREATE TABLE IF NOT EXISTS team_invites ( 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), + 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) ) """); @@ -353,8 +355,8 @@ CREATE TABLE IF NOT EXISTS api_key_scopes ( scope TEXT CHECK (scope in ('PROJECT', 'USER')), project_id TEXT, permissions INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES projects(id), - FOREIGN KEY (uuid) REFERENCES api_keys(uuid), + 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) ) """); @@ -364,7 +366,7 @@ CREATE TABLE IF NOT EXISTS passwords ( salt BLOB NOT NULL, hash BLOB NOT NULL, last_updated INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (user_id) ) """); @@ -372,18 +374,39 @@ PRIMARY KEY (user_id) CREATE TABLE IF NOT EXISTS integration_modrinth ( user_id TEXT NOT NULL, modrinth_id TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (user_id) ) """); - statement.addBatch(""" + statement.addBatch(""" CREATE TABLE IF NOT EXISTS integration_discord ( user_id TEXT NOT NULL, discord_id TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (user_id) ) """); + Function.create( + connection, "generate_natural_id", new Function() { + @Override + protected void xFunc() throws SQLException { + String table = this.value_text(0); + String key = this .value_text(1); + int length = this.value_int(2); + this.result(NaturalId.generate(table, key, length)); + } + } + ); + Function.create( + connection, "generate_natural_id_from_number", new Function() { + @Override + protected void xFunc() throws SQLException { + int number = this.value_int(0); + int length = this.value_int(1); + this.result(NaturalId.generateFromNumber(number, length)); + } + } + ); statement.executeBatch(); } catch (SQLException ex) { LOG.error("Failed to create database tables. ", ex); diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 8853e12..5c23986 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -17,7 +17,7 @@ public final class NaturalId { // 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 ALPHABET = "abcdefghijklmnopqrstuvwxyz"; private NaturalId() {} @@ -33,24 +33,45 @@ public static boolean isValidLegacy(String id) { return isValid(id) || PATTERN_LEGACY.matcher(id).hasMatch(); } - private static String generateUnchecked(RandomGenerator random) { + private static String generateUnchecked(int length) { StringBuilder builder = new StringBuilder(); - for (int i = 0; i < 5; i++) { - builder.append(alphabet.charAt(random.nextInt(alphabet.length()))); + for (int i = 0; i < length; i++) { + builder.append(ALPHABET.charAt(RandomGenerator.getDefault().nextInt(ALPHABET.length()))); } return builder.toString(); } @NotNull - public static String generate(String table, String key) throws SQLException { + public static String generateFromNumber(int number, int length) { + number += ALPHABET.length(); // hack, do not remove or tiny pineapple will steal your computer + return generateFromNumberRecursive(number, length); + } + + @NotNull + private static String generateFromNumberRecursive(int number, int length) { + int iterations = number / ALPHABET.length(); + int remainder = number % ALPHABET.length(); + if ((number - ALPHABET.length()) / ALPHABET.length() > length) { + throw new IllegalArgumentException("Number " + number + " cannot be represented in this length " + length); + } + + if (iterations == 0) { + return "" + ALPHABET.charAt(remainder); + } else { + String result = generateFromNumberRecursive(iterations - 1, length); + return result + ALPHABET.charAt(remainder); + } + } + + @NotNull + public static String generate(String table, String key, int length) throws SQLException { String id = null; try (Connection connection1 = ModGardenBackend.createDatabaseConnection()) { while (id == null) { - String naturalId = generateUnchecked(RandomGenerator.getDefault()); - var exists = connection1.prepareStatement("SELECT true FROM ? WHERE ? = ?"); - exists.setString(1, table); - exists.setString(2, key); - exists.setString(3, naturalId); + String naturalId = generateUnchecked(length); + var exists = connection1.prepareStatement("SELECT true FROM " + table + " WHERE ? = ?"); + exists.setString(1, key); + exists.setString(2, naturalId); if (!exists.execute() && !isReserved(naturalId)) { id = naturalId; } 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 index 336be7b..2acc717 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,7 +1,9 @@ package net.modgarden.backend.data.fixer.fix; +import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.fixer.DatabaseFix; import org.jetbrains.annotations.Nullable; +import org.sqlite.Function; import java.sql.Connection; import java.sql.SQLException; @@ -15,6 +17,53 @@ public V5ToV6() { @Override public @Nullable Consumer fix(Connection connection) throws SQLException { var statement = connection.createStatement(); + + statement.execute("PRAGMA foreign_keys = ON"); + + + statement.execute("ALTER TABLE users RENAME TO users_old"); + statement.execute(""" + 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, + created INTEGER NOT NULL, + permissions INTEGER NOT NULL, + PRIMARY KEY(id) + ) + """); + statement.execute(""" + INSERT INTO users (id, username, display_name, pronouns, avatar_url, created, permissions) + SELECT id, username, display_name, pronouns, avatar_url, created, permissions from users_old + """); + + + statement.execute("CREATE TABLE submissions_mr AS SELECT * FROM submissions"); + statement.execute("ALTER TABLE submissions_mr ADD COLUMN modrinth_id TEXT"); + + statement.execute(""" + UPDATE submissions_mr + SET modrinth_id = ( + SELECT modrinth_id FROM projects WHERE submissions_mr.project_id = projects.id + ) + """); + + statement.execute("ALTER TABLE projects RENAME TO projects_old"); + statement.execute(""" + CREATE TABLE IF NOT EXISTS projects ( + id TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + PRIMARY KEY (id) + ) + """); + statement.execute(""" + INSERT INTO projects (id, slug) + SELECT id, slug FROM projects_old + """); + + statement.addBatch(""" CREATE TABLE IF NOT EXISTS api_keys ( uuid BLOB NOT NULL, @@ -22,7 +71,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( salt BLOB NOT NULL, hash BLOB UNIQUE NOT NULL, expires INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (uuid) ) """); @@ -32,8 +81,8 @@ CREATE TABLE IF NOT EXISTS api_key_scopes ( scope TEXT CHECK (scope in ('project', 'user')), project_id TEXT, permissions INTEGER NOT NULL, - FOREIGN KEY (project_id) REFERENCES projects(id), - FOREIGN KEY (uuid) REFERENCES api_keys(uuid), + 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) ) """); @@ -43,7 +92,7 @@ CREATE TABLE IF NOT EXISTS passwords ( salt BLOB NOT NULL, hash BLOB NOT NULL, last_updated INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (user_id) ) """); @@ -51,7 +100,7 @@ PRIMARY KEY (user_id) CREATE TABLE IF NOT EXISTS integration_modrinth ( user_id TEXT NOT NULL, modrinth_id TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (user_id) ) """); @@ -59,7 +108,7 @@ PRIMARY KEY (user_id) CREATE TABLE IF NOT EXISTS integration_discord ( user_id TEXT NOT NULL, discord_id TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (user_id) ) """); @@ -68,7 +117,7 @@ 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), + FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (submission_id) ) """); @@ -79,8 +128,8 @@ CREATE TABLE IF NOT EXISTS project_roles ( 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), - FOREIGN KEY (user_id) REFERENCES users(id) + 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 ) """); @@ -90,83 +139,157 @@ CREATE UNIQUE INDEX idx_project_roles_two_ids ON project_roles(project_id, user_ statement.executeBatch(); + + statement.execute("ALTER TABLE submissions RENAME TO submissions_old"); + statement.execute(""" + 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.execute(""" + INSERT INTO submissions (id, event, project_id, submitted) + SELECT id, event, project_id, submitted from submissions_old + """); + + statement.execute(""" + 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.execute(""" INSERT INTO integration_modrinth (user_id, modrinth_id) - SELECT id, modrinth_id FROM users + SELECT id, modrinth_id FROM users_old WHERE modrinth_id NOT NULL """); statement.execute(""" INSERT INTO integration_discord (user_id, discord_id) - SELECT id, discord_id FROM users + SELECT id, discord_id FROM users_old """); - statement.execute("ALTER TABLE users RENAME TO users_old"); statement.execute(""" - CREATE TABLE users AS SELECT id, id, username, display_name, pronouns, avatar_url, created, permissions FROM users_old + 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.execute(""" + INSERT OR REPLACE INTO project_roles_temp (project_id, user_id) + SELECT project_id, user_id FROM project_authors + """); - statement.execute("CREATE TABLE submissions_mr AS SELECT * FROM submissions"); - statement.execute("ALTER TABLE submissions_mr ADD COLUMN modrinth_id TEXT"); + statement.execute(""" + INSERT INTO project_roles (project_id, user_id, permissions, role_name) + SELECT project_id, user_id, permissions, role_name FROM project_roles_temp + """); + statement.execute("ALTER TABLE minecraft_accounts RENAME TO minecraft_accounts_old"); statement.execute(""" - UPDATE submissions_mr - SET modrinth_id = ( - SELECT modrinth_id FROM projects WHERE submissions_mr.project_id = projects.id + CREATE TABLE IF NOT EXISTS minecraft_accounts ( + 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.execute("ALTER TABLE submissions RENAME TO submissions_old"); statement.execute(""" - CREATE TABLE submissions AS SELECT * FROM submissions_old - WHERE NOT modrinth_version_id + INSERT INTO minecraft_accounts (uuid, user_id) + SELECT uuid, user_id FROM minecraft_accounts_old """); - statement.execute("ALTER TABLE projects RENAME TO projects_old"); + statement.execute("ALTER TABLE award_instances RENAME TO award_instances_old"); statement.execute(""" - CREATE TABLE projects AS SELECT * FROM projects_old - WHERE NOT modrinth_id + 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.execute(""" - 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 + 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.execute("ALTER TABLE team_invites RENAME TO team_invites_old"); statement.execute(""" - CREATE TABLE IF NOT EXISTS project_roles_temp ( + CREATE TABLE IF NOT EXISTS team_invites ( + code TEXT NOT NULL, 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), - FOREIGN KEY (user_id) REFERENCES users(id) + expires INTEGER NOT NULL, + role TEXT NOT NULL CHECK (role IN ('author', 'builder')), + 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.execute(""" - INSERT OR REPLACE INTO project_roles_temp (project_id, user_id) - SELECT project_id, user_id FROM project_authors + INSERT INTO team_invites (code, project_id, user_id, expires, role) + SELECT code, project_id, user_id, expires, role FROM team_invites """); + Function.create( + connection, "generate_natural_id", new Function() { + @Override + protected void xFunc() throws SQLException { + String table = this.value_text(0); + String key = this .value_text(1); + int length = this.value_int(2); + this.result(NaturalId.generate(table, key, length)); + } + } + ); + + Function.create( + connection, "generate_natural_id_from_number", new Function() { + @Override + protected void xFunc() throws SQLException { + int number = this.value_int(0); + int length = this.value_int(1); + this.result(NaturalId.generateFromNumber(number, length)); + } + } + ); + statement.execute(""" - INSERT INTO project_roles (project_id, user_id, permissions, role_name) - SELECT project_id, user_id, permissions, role_name FROM project_roles_temp + WITH cnt(i) AS ( + SELECT 1 UNION SELECT i+1 FROM cnt + ) + UPDATE users + SET id = concat('zzz', generate_natural_id_from_number(ROWID - 1, 2)) """); return dropConnection -> { try { var dropStatement = dropConnection.createStatement(); - dropStatement.execute("DROP TABLE users_old"); + dropStatement.execute("PRAGMA foreign_keys = ON"); dropStatement.execute("DROP TABLE submissions_old"); dropStatement.execute("DROP TABLE submissions_mr"); - dropStatement.execute("ALTER TABLE submissions DROP COLUMN modrinth_version_id"); - dropStatement.execute("DROP TABLE projects_old"); dropStatement.execute("DROP TABLE project_builders"); dropStatement.execute("DROP TABLE project_authors"); dropStatement.execute("DROP TABLE project_roles_temp"); + dropStatement.execute("DROP TABLE minecraft_accounts_old"); + dropStatement.execute("DROP TABLE award_instances_old"); + dropStatement.execute("DROP TABLE team_invites_old"); + dropStatement.execute("DROP TABLE projects_old"); + dropStatement.execute("DROP TABLE users_old"); } catch (SQLException e) { throw new RuntimeException(e); } diff --git a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java index 7a05150..284a060 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java @@ -100,7 +100,7 @@ public static void discordBotRegister(Context ctx) { return; } - insertStatement.setString(1, NaturalId.generate("users", "id")); + insertStatement.setString(1, NaturalId.generate("users", "id", 5)); insertStatement.setString(2, username); insertStatement.setString(3, displayName); insertStatement.setString(4, body.id); 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 index 6d8378d..71e2a31 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java @@ -132,7 +132,7 @@ public static void submitModrinth(Context ctx) { } if (projectId == null) { - projectId = NaturalId.generate("projects", "id"); + projectId = NaturalId.generate("projects", "id", 5); projectInsertStatement.setString(1, projectId); projectInsertStatement.setString(2, slug); projectInsertStatement.setString(3, modrinthProject.id); @@ -145,7 +145,9 @@ public static void submitModrinth(Context ctx) { projectAuthorsStatement.execute(); } - String submissionId = NaturalId.generate("submissions", "id"); + String submissionId = NaturalId.generate("submissions", "id", + 5 + ); submissionStatement.setString(1, submissionId); submissionStatement.setString(2, projectId); submissionStatement.setString(3, event.id()); From c0e5a9713ba20d068bcc9e2b0add7e0ded9e789a Mon Sep 17 00:00:00 2001 From: sylv256 Date: Thu, 2 Oct 2025 12:15:54 -0400 Subject: [PATCH 19/98] refactor: use addBatch instead of execute --- .../backend/data/fixer/fix/V5ToV6.java | 85 ++++++++++--------- 1 file changed, 43 insertions(+), 42 deletions(-) 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 index 2acc717..50eb550 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -18,11 +18,11 @@ public V5ToV6() { public @Nullable Consumer fix(Connection connection) throws SQLException { var statement = connection.createStatement(); - statement.execute("PRAGMA foreign_keys = ON"); + statement.addBatch("PRAGMA foreign_keys = ON"); - statement.execute("ALTER TABLE users RENAME TO users_old"); - statement.execute(""" + 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, @@ -34,31 +34,31 @@ CREATE TABLE IF NOT EXISTS users ( PRIMARY KEY(id) ) """); - statement.execute(""" + statement.addBatch(""" INSERT INTO users (id, username, display_name, pronouns, avatar_url, created, permissions) SELECT id, username, display_name, pronouns, avatar_url, created, permissions from users_old """); - statement.execute("CREATE TABLE submissions_mr AS SELECT * FROM submissions"); - statement.execute("ALTER TABLE submissions_mr ADD COLUMN modrinth_id TEXT"); + statement.addBatch("CREATE TABLE submissions_mr AS SELECT * FROM submissions"); + statement.addBatch("ALTER TABLE submissions_mr ADD COLUMN modrinth_id TEXT"); - statement.execute(""" + statement.addBatch(""" UPDATE submissions_mr SET modrinth_id = ( SELECT modrinth_id FROM projects WHERE submissions_mr.project_id = projects.id ) """); - statement.execute("ALTER TABLE projects RENAME TO projects_old"); - statement.execute(""" + statement.addBatch("ALTER TABLE projects RENAME TO projects_old"); + statement.addBatch(""" CREATE TABLE IF NOT EXISTS projects ( id TEXT UNIQUE NOT NULL, slug TEXT UNIQUE NOT NULL, PRIMARY KEY (id) ) """); - statement.execute(""" + statement.addBatch(""" INSERT INTO projects (id, slug) SELECT id, slug FROM projects_old """); @@ -137,11 +137,9 @@ FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE CREATE UNIQUE INDEX idx_project_roles_two_ids ON project_roles(project_id, user_id) """); - statement.executeBatch(); - - statement.execute("ALTER TABLE submissions RENAME TO submissions_old"); - statement.execute(""" + 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, @@ -152,28 +150,28 @@ FOREIGN KEY (event) REFERENCES events(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY(id) ) """); - statement.execute(""" + statement.addBatch(""" INSERT INTO submissions (id, event, project_id, submitted) SELECT id, event, project_id, submitted from submissions_old """); - statement.execute(""" + 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.execute(""" + statement.addBatch(""" INSERT INTO integration_modrinth (user_id, modrinth_id) SELECT id, modrinth_id FROM users_old WHERE modrinth_id NOT NULL """); - statement.execute(""" + statement.addBatch(""" INSERT INTO integration_discord (user_id, discord_id) SELECT id, discord_id FROM users_old """); - statement.execute(""" + statement.addBatch(""" CREATE TABLE IF NOT EXISTS project_roles_temp ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, @@ -184,18 +182,18 @@ FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE ) """); - statement.execute(""" + statement.addBatch(""" INSERT OR REPLACE INTO project_roles_temp (project_id, user_id) SELECT project_id, user_id FROM project_authors """); - statement.execute(""" + 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.execute("ALTER TABLE minecraft_accounts RENAME TO minecraft_accounts_old"); - statement.execute(""" + statement.addBatch("ALTER TABLE minecraft_accounts RENAME TO minecraft_accounts_old"); + statement.addBatch(""" CREATE TABLE IF NOT EXISTS minecraft_accounts ( uuid TEXT UNIQUE NOT NULL, user_id TEXT UNIQUE NOT NULL, @@ -203,13 +201,13 @@ FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (uuid) ) """); - statement.execute(""" + statement.addBatch(""" INSERT INTO minecraft_accounts (uuid, user_id) SELECT uuid, user_id FROM minecraft_accounts_old """); - statement.execute("ALTER TABLE award_instances RENAME TO award_instances_old"); - statement.execute(""" + 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, @@ -222,13 +220,13 @@ FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELE PRIMARY KEY (award_id, awarded_to) ) """); - statement.execute(""" + 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.execute("ALTER TABLE team_invites RENAME TO team_invites_old"); - statement.execute(""" + 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, @@ -240,7 +238,7 @@ FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (code) ) """); - statement.execute(""" + statement.addBatch(""" INSERT INTO team_invites (code, project_id, user_id, expires, role) SELECT code, project_id, user_id, expires, role FROM team_invites """); @@ -268,7 +266,7 @@ protected void xFunc() throws SQLException { } ); - statement.execute(""" + statement.addBatch(""" WITH cnt(i) AS ( SELECT 1 UNION SELECT i+1 FROM cnt ) @@ -276,20 +274,23 @@ WITH cnt(i) AS ( SET id = concat('zzz', generate_natural_id_from_number(ROWID - 1, 2)) """); + statement.executeBatch(); + return dropConnection -> { try { var dropStatement = dropConnection.createStatement(); - dropStatement.execute("PRAGMA foreign_keys = ON"); - dropStatement.execute("DROP TABLE submissions_old"); - dropStatement.execute("DROP TABLE submissions_mr"); - dropStatement.execute("DROP TABLE project_builders"); - dropStatement.execute("DROP TABLE project_authors"); - dropStatement.execute("DROP TABLE project_roles_temp"); - dropStatement.execute("DROP TABLE minecraft_accounts_old"); - dropStatement.execute("DROP TABLE award_instances_old"); - dropStatement.execute("DROP TABLE team_invites_old"); - dropStatement.execute("DROP TABLE projects_old"); - dropStatement.execute("DROP TABLE users_old"); + dropStatement.addBatch("PRAGMA foreign_keys = ON"); + dropStatement.addBatch("DROP TABLE submissions_old"); + dropStatement.addBatch("DROP TABLE submissions_mr"); + dropStatement.addBatch("DROP TABLE project_builders"); + dropStatement.addBatch("DROP TABLE project_authors"); + dropStatement.addBatch("DROP TABLE project_roles_temp"); + dropStatement.addBatch("DROP TABLE minecraft_accounts_old"); + dropStatement.addBatch("DROP TABLE award_instances_old"); + dropStatement.addBatch("DROP TABLE team_invites_old"); + dropStatement.addBatch("DROP TABLE projects_old"); + dropStatement.addBatch("DROP TABLE users_old"); + dropStatement.executeBatch(); } catch (SQLException e) { throw new RuntimeException(e); } From 73893cca1441b14ed8ea9085e25d5080b14a89ad Mon Sep 17 00:00:00 2001 From: sylv256 Date: Thu, 2 Oct 2025 13:02:08 -0400 Subject: [PATCH 20/98] feat: fix and add reserved accounts --- .../modgarden/backend/data/fixer/fix/V5ToV6.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 index 50eb550..590e95c 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -254,7 +254,6 @@ protected void xFunc() throws SQLException { } } ); - Function.create( connection, "generate_natural_id_from_number", new Function() { @Override @@ -274,6 +273,20 @@ WITH cnt(i) AS ( SET id = concat('zzz', generate_natural_id_from_number(ROWID - 1, 2)) """); + statement.addBatch(""" + UPDATE users + SET id = 'mgacc', permissions = 1, pronouns = 'they/it' + WHERE username == 'mod_garden' + """); + + statement.addBatch(""" + INSERT INTO users VALUES ('grbot', 'gardenbot', 'GardenBot', 'it/its', NULL, unixepoch('subsec') * 1000, 1) + """); + + statement.addBatch(""" + INSERT INTO users VALUES ('abcde', 'tiny_pineapple', 'Tiny Pineapple', 'it/its', NULL, unixepoch('subsec') * 1000, 0) + """); + statement.executeBatch(); return dropConnection -> { From 1ca6b8d9528224942a54d6fe70b4a9becc12a6f3 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Thu, 2 Oct 2025 13:26:37 -0400 Subject: [PATCH 21/98] fix: properly generate NIDs from any number and length --- .../net/modgarden/backend/ModGardenBackend.java | 3 +++ .../net/modgarden/backend/data/NaturalId.java | 17 ++++++++++------- .../backend/data/fixer/fix/V5ToV6.java | 1 + 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index b3d3eff..04c8172 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -72,6 +72,9 @@ public static void main(String[] args) { if ("development".equals(DOTENV.get("env"))) ((ch.qos.logback.classic.Logger)LOG).setLevel(Level.DEBUG); + ModGardenBackend.LOG.debug("1 {}, 4 {}, 26 {}, 29 {}, 52 {}, 53 {}, 79 {}", NaturalId.generateFromNumber(1, 2), NaturalId.generateFromNumber(4, 2), NaturalId.generateFromNumber(26, 2), NaturalId.generateFromNumber(29, 2), NaturalId.generateFromNumber(52, 2), NaturalId.generateFromNumber(53, 2), NaturalId.generateFromNumber(79, 2)); + ModGardenBackend.LOG.debug("1 {}, 4 {}, 26 {}, 29 {}, 52 {}, 53 {}, 79 {}, 675 {}, 676 {}, 677 {}", NaturalId.generateFromNumber(1, 3), NaturalId.generateFromNumber(4, 3), NaturalId.generateFromNumber(26, 3), NaturalId.generateFromNumber(29, 3), NaturalId.generateFromNumber(52, 3), NaturalId.generateFromNumber(53, 3), NaturalId.generateFromNumber(79, 3), NaturalId.generateFromNumber(675, 3), NaturalId.generateFromNumber(676, 3), NaturalId.generateFromNumber(677, 3)); + try { boolean createdFile = new File("./database.db").createNewFile(); DatabaseFixer.createFixers(); diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 5c23986..e426d9d 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -43,22 +43,25 @@ private static String generateUnchecked(int length) { @NotNull public static String generateFromNumber(int number, int length) { - number += ALPHABET.length(); // hack, do not remove or tiny pineapple will steal your computer - return generateFromNumberRecursive(number, length); +// number += ALPHABET.length(); // hack, do not remove or tiny pineapple will steal your computer + String result = generateFromNumberRecursive(number); + if (result.length() > length) { + throw new IllegalArgumentException("Number " + number + " cannot be represented in this length " + length); + } else { + String padding = "a".repeat(length - result.length()); + return padding + result; + } } @NotNull - private static String generateFromNumberRecursive(int number, int length) { + private static String generateFromNumberRecursive(int number) { int iterations = number / ALPHABET.length(); int remainder = number % ALPHABET.length(); - if ((number - ALPHABET.length()) / ALPHABET.length() > length) { - throw new IllegalArgumentException("Number " + number + " cannot be represented in this length " + length); - } if (iterations == 0) { return "" + ALPHABET.charAt(remainder); } else { - String result = generateFromNumberRecursive(iterations - 1, length); + String result = generateFromNumberRecursive(iterations); return result + ALPHABET.charAt(remainder); } } 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 index 590e95c..623832d 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,5 +1,6 @@ package net.modgarden.backend.data.fixer.fix; +import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.fixer.DatabaseFix; import org.jetbrains.annotations.Nullable; From ff0621fa054d311ae8f6fed5a89f8c0e6ceaf12b Mon Sep 17 00:00:00 2001 From: sylv256 Date: Thu, 2 Oct 2025 15:16:46 -0400 Subject: [PATCH 22/98] feat: new global permissions --- src/main/java/net/modgarden/backend/data/Permission.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index 98b1116..8ec6bc9 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -12,7 +12,11 @@ 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"); + ADMINISTRATOR(0x1, "administrator"), + EDIT_PROFILES(0x2, "edit_profiles"), + MODERATE_USERS(0x4, "moderate_users"), + EDIT_PROJECTS(0x8, "edit_projects"), + MODERATE_PROJECTS(0x10, "moderate_projects"),; public static final Codec CODEC = Codec.STRING.flatXmap(string -> { try { From 4e1d887e468b2ac5951e89fba1bb1f7017d90977 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Thu, 2 Oct 2025 15:33:24 -0400 Subject: [PATCH 23/98] feat: new global permissions --- .../modgarden/backend/data/Permission.java | 34 ++++++++++++++----- .../backend/data/PermissionKind.java | 7 ++++ .../modgarden/backend/data/profile/User.java | 7 ++-- 3 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/data/PermissionKind.java diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index 8ec6bc9..2ddabcc 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -6,17 +6,20 @@ import java.util.ArrayList; import java.util.List; +import static net.modgarden.backend.data.PermissionKind.*; + // 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(0x1, "administrator"), - EDIT_PROFILES(0x2, "edit_profiles"), - MODERATE_USERS(0x4, "moderate_users"), - EDIT_PROJECTS(0x8, "edit_projects"), - MODERATE_PROJECTS(0x10, "moderate_projects"),; + ADMINISTRATOR(0x1, "administrator", ALL), + EDIT_PROFILES(0x2, "edit_profiles", GLOBAL), + MODERATE_USERS(0x4, "moderate_users", GLOBAL), + EDIT_PROJECTS(0x8, "edit_projects", GLOBAL), + MODERATE_PROJECTS(0x10, "moderate_projects", GLOBAL), + UPLOAD_TO_CDN(0x20, "upload_to_cdn", GLOBAL),; public static final Codec CODEC = Codec.STRING.flatXmap(string -> { try { @@ -25,19 +28,22 @@ 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.LONG.xmap(l -> fromLong(l, GLOBAL), Permission::toLong)); + public static final Codec> PROJECT_LIST_CODEC = Codec.withAlternative(CODEC.listOf(), Codec.LONG.xmap(l -> fromLong(l, PROJECT), Permission::toLong)); private final long bit; private final String name; + private final PermissionKind kind; - Permission(int bit, String name) { + Permission(int bit, String name, PermissionKind kind) { this.bit = bit; this.name = name; + this.kind = kind; } - public static List fromLong(long value) { + public static List fromLong(long value, PermissionKind kind) { List permissions = new ArrayList<>(); - for (Permission permission : Permission.values()) { + for (Permission permission : Permission.values(kind)) { if (hasPermissionRaw(value, permission)) { permissions.add(permission); } @@ -73,6 +79,16 @@ private static boolean hasPermissionRaw(long userPermissions, Permission permiss return (userPermissions & permission.bit) != 0; } + private static List values(PermissionKind kind) { + List permissions = new ArrayList<>(); + for (Permission permission : Permission.values()) { + if (permission.kind == ALL || permission.kind == kind) { + permissions.add(permission); + } + } + return permissions; + } + public String getName() { return name; } diff --git a/src/main/java/net/modgarden/backend/data/PermissionKind.java b/src/main/java/net/modgarden/backend/data/PermissionKind.java new file mode 100644 index 0000000..ae5dd41 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/PermissionKind.java @@ -0,0 +1,7 @@ +package net.modgarden.backend.data; + +public enum PermissionKind { + ALL, + GLOBAL, + PROJECT, +} diff --git a/src/main/java/net/modgarden/backend/data/profile/User.java b/src/main/java/net/modgarden/backend/data/profile/User.java index 1702434..bb1c4c4 100644 --- a/src/main/java/net/modgarden/backend/data/profile/User.java +++ b/src/main/java/net/modgarden/backend/data/profile/User.java @@ -7,10 +7,10 @@ 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.PermissionKind; import net.modgarden.backend.data.award.AwardInstance; import net.modgarden.backend.data.event.Event; import net.modgarden.backend.data.event.Project; @@ -43,7 +43,6 @@ public record User(String id, 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]+?$"; @@ -60,7 +59,7 @@ public record User(String id, 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) + Permission.GLOBAL_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); @@ -185,7 +184,7 @@ private static User innerQuery(String whereStatement, String id) { events, minecraftAccounts, awards, - Permission.fromLong(result.getLong("permissions")) + Permission.fromLong(result.getLong("permissions"), PermissionKind.GLOBAL) ); } catch (SQLException ex) { ModGardenBackend.LOG.error("Exception in SQL query.", ex); From d2e0a367744932f66a08e47bc9e5195d28ac6c23 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 4 Oct 2025 14:23:38 -0400 Subject: [PATCH 24/98] chore(gitignore): thanks linux (.directory) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From dbbf8d4fc85c11739c5625cc928da83a13534c49 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 4 Oct 2025 23:11:09 -0400 Subject: [PATCH 25/98] refactor: events and submissions --- .../modgarden/backend/ModGardenBackend.java | 133 +++++++----- .../backend/data/fixer/fix/V5ToV6.java | 198 ++++++++++++++---- 2 files changed, 229 insertions(+), 102 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 04c8172..3f86252 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -44,6 +44,7 @@ import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.Statement; +import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.function.Supplier; @@ -231,15 +232,71 @@ PRIMARY KEY(id) ) """); statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_keys ( + uuid BLOB NOT NULL, + user_id TEXT NOT NULL, + salt BLOB NOT NULL, + hash BLOB NOT NULL, + expires INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + PRIMARY KEY (uuid) + ) + """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS api_key_scopes ( + uuid BLOB NOT NULL, + scope TEXT 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, + salt BLOB NOT NULL, + hash BLOB 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, - discord_role_id TEXT, minecraft_version TEXT NOT NULL, loader TEXT NOT NULL, - registration_time INTEGER 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, @@ -247,6 +304,14 @@ 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, slug TEXT UNIQUE NOT NULL, @@ -288,14 +353,6 @@ PRIMARY KEY (submission_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) ON UPDATE CASCADE ON DELETE CASCADE, - PRIMARY KEY (uuid) - ) - """); - statement.addBatch(""" CREATE TABLE IF NOT EXISTS awards ( id TEXT UNIQUE NOT NULL, slug TEXT UNIQUE NOT NULL, @@ -341,54 +398,6 @@ FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (code) ) """); - statement.addBatch(""" - CREATE TABLE IF NOT EXISTS api_keys ( - uuid BLOB NOT NULL, - user_id TEXT NOT NULL, - salt BLOB NOT NULL, - hash BLOB NOT NULL, - expires INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), - PRIMARY KEY (uuid) - ) - """); - statement.addBatch(""" - CREATE TABLE IF NOT EXISTS api_key_scopes ( - uuid BLOB NOT NULL, - scope TEXT 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, - salt BLOB NOT NULL, - hash BLOB 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 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 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) - ) - """); Function.create( connection, "generate_natural_id", new Function() { @Override @@ -410,6 +419,14 @@ protected void xFunc() throws SQLException { } } ); + Function.create( + connection, "unix_millis", new Function() { + @Override + protected void xFunc() throws SQLException { + this.result(Instant.now().toEpochMilli()); + } + } + ); statement.executeBatch(); } catch (SQLException ex) { LOG.error("Failed to create database tables. ", ex); 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 index 623832d..ff5fba9 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -8,6 +8,7 @@ import java.sql.Connection; import java.sql.SQLException; +import java.time.Instant; import java.util.function.Consumer; public class V5ToV6 extends DatabaseFix { @@ -19,6 +20,47 @@ public V5ToV6() { public @Nullable Consumer fix(Connection connection) throws SQLException { var statement = connection.createStatement(); + Function.create( + connection, "generate_natural_id", new Function() { + @Override + protected void xFunc() throws SQLException { + String table = this.value_text(0); + String key = this .value_text(1); + int length = this.value_int(2); + this.result(NaturalId.generate(table, key, length)); + } + } + ); + Function.create( + connection, "generate_natural_id_from_number", new Function() { + @Override + protected void xFunc() throws SQLException { + int number = this.value_int(0); + int length = this.value_int(1); + this.result(NaturalId.generateFromNumber(number, length)); + } + } + ); + Function.create( + connection, "unix_millis", new Function() { + @Override + protected void xFunc() throws SQLException { + this.result(Instant.now().toEpochMilli()); + } + } + ); + + // 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"); @@ -51,6 +93,31 @@ INSERT INTO users (id, username, display_name, pronouns, avatar_url, created, pe ) """); + statement.addBatch(""" + WITH cnt(i) AS ( + SELECT 1 UNION SELECT i+1 FROM cnt + ) + UPDATE submissions_mr + SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + """); + + 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("PRAGMA foreign_keys = OFF"); + 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("PRAGMA foreign_keys = ON"); + statement.addBatch("ALTER TABLE projects RENAME TO projects_old"); statement.addBatch(""" CREATE TABLE IF NOT EXISTS projects ( @@ -64,7 +131,6 @@ INSERT INTO projects (id, slug) SELECT id, slug FROM projects_old """); - statement.addBatch(""" CREATE TABLE IF NOT EXISTS api_keys ( uuid BLOB NOT NULL, @@ -98,7 +164,7 @@ PRIMARY KEY (user_id) ) """); statement.addBatch(""" - CREATE TABLE IF NOT EXISTS integration_modrinth ( + 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, @@ -106,22 +172,13 @@ PRIMARY KEY (user_id) ) """); statement.addBatch(""" - CREATE TABLE IF NOT EXISTS integration_discord ( + 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 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 project_roles ( @@ -139,6 +196,52 @@ CREATE UNIQUE INDEX idx_project_roles_two_ids ON project_roles(project_id, user_ """); + 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(""" + 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("ALTER TABLE submissions RENAME TO submissions_old"); statement.addBatch(""" CREATE TABLE IF NOT EXISTS submissions ( @@ -155,23 +258,27 @@ PRIMARY KEY(id) INSERT INTO submissions (id, event, project_id, submitted) SELECT id, event, project_id, submitted from submissions_old """); - 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 + WITH cnt(i) AS ( + SELECT 1 UNION SELECT i+1 FROM cnt + ) + UPDATE submissions + SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) """); + + statement.addBatch(""" - INSERT INTO integration_modrinth (user_id, modrinth_id) + 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 integration_discord (user_id, discord_id) + 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, @@ -195,7 +302,7 @@ INSERT INTO project_roles (project_id, user_id, permissions, role_name) statement.addBatch("ALTER TABLE minecraft_accounts RENAME TO minecraft_accounts_old"); statement.addBatch(""" - CREATE TABLE IF NOT EXISTS minecraft_accounts ( + 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, @@ -203,7 +310,7 @@ PRIMARY KEY (uuid) ) """); statement.addBatch(""" - INSERT INTO minecraft_accounts (uuid, user_id) + INSERT INTO user_integration_minecraft (uuid, user_id) SELECT uuid, user_id FROM minecraft_accounts_old """); @@ -244,28 +351,6 @@ INSERT INTO team_invites (code, project_id, user_id, expires, role) SELECT code, project_id, user_id, expires, role FROM team_invites """); - Function.create( - connection, "generate_natural_id", new Function() { - @Override - protected void xFunc() throws SQLException { - String table = this.value_text(0); - String key = this .value_text(1); - int length = this.value_int(2); - this.result(NaturalId.generate(table, key, length)); - } - } - ); - Function.create( - connection, "generate_natural_id_from_number", new Function() { - @Override - protected void xFunc() throws SQLException { - int number = this.value_int(0); - int length = this.value_int(1); - this.result(NaturalId.generateFromNumber(number, length)); - } - } - ); - statement.addBatch(""" WITH cnt(i) AS ( SELECT 1 UNION SELECT i+1 FROM cnt @@ -281,13 +366,37 @@ WITH cnt(i) AS ( """); statement.addBatch(""" - INSERT INTO users VALUES ('grbot', 'gardenbot', 'GardenBot', 'it/its', NULL, unixepoch('subsec') * 1000, 1) + INSERT INTO users VALUES ('grbot', 'gardenbot', 'GardenBot', 'it/its', NULL, unix_millis(), 1) """); statement.addBatch(""" - INSERT INTO users VALUES ('abcde', 'tiny_pineapple', 'Tiny Pineapple', 'it/its', NULL, unixepoch('subsec') * 1000, 0) + INSERT INTO users VALUES ('abcde', 'tiny_pineapple', 'Tiny Pineapple', 'it/its', NULL, unix_millis(), 0) """); + statement.addBatch(""" + WITH cnt(i) AS ( + SELECT 1 UNION SELECT i+1 FROM cnt + ) + UPDATE projects + SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + """); + + statement.addBatch(""" + WITH cnt(i) AS ( + SELECT 1 UNION SELECT i+1 FROM cnt + ) + UPDATE events + SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + """); + statement.addBatch(""" + WITH cnt(i) AS ( + SELECT 1 UNION SELECT i+1 FROM cnt + ) + UPDATE events + SET slug = clean_slug_mg(slug) + """); + + statement.executeBatch(); return dropConnection -> { @@ -304,6 +413,7 @@ INSERT INTO users VALUES ('abcde', 'tiny_pineapple', 'Tiny Pineapple', 'it/its', dropStatement.addBatch("DROP TABLE team_invites_old"); dropStatement.addBatch("DROP TABLE projects_old"); dropStatement.addBatch("DROP TABLE users_old"); + dropStatement.addBatch("DROP TABLE events_old"); dropStatement.executeBatch(); } catch (SQLException e) { throw new RuntimeException(e); From b0a33995932c65b6415990ddfc41256c237e472e Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sun, 5 Oct 2025 17:51:45 -0400 Subject: [PATCH 26/98] refactor: update role and permissions columns in team_invites --- src/main/java/net/modgarden/backend/ModGardenBackend.java | 3 ++- .../java/net/modgarden/backend/data/fixer/fix/V5ToV6.java | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 3f86252..bf31011 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -392,7 +392,8 @@ CREATE TABLE IF NOT EXISTS team_invites ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, - role TEXT NOT NULL CHECK (role IN ('author', 'builder')), + 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) 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 index ff5fba9..91c8e1b 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,6 +1,5 @@ package net.modgarden.backend.data.fixer.fix; -import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.fixer.DatabaseFix; import org.jetbrains.annotations.Nullable; @@ -340,7 +339,8 @@ CREATE TABLE IF NOT EXISTS team_invites ( project_id TEXT NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, - role TEXT NOT NULL CHECK (role IN ('author', 'builder')), + 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) @@ -348,7 +348,7 @@ 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 + SELECT code, project_id, user_id, expires, role FROM team_invites_old """); statement.addBatch(""" From 429c25bc5f0ed19daaec6bf4d5552eef1464a8f5 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Mon, 6 Oct 2025 09:52:31 -0400 Subject: [PATCH 27/98] refactor: update role and permissions columns in team_invites --- .../modgarden/backend/ModGardenBackend.java | 125 +--- .../modgarden/backend/data/BackendError.java | 7 +- .../modgarden/backend/data/Integration.java | 15 + .../modgarden/backend/data/Permission.java | 45 +- .../backend/data/PermissionKind.java | 2 +- .../modgarden/backend/data/Permissions.java | 44 ++ .../modgarden/backend/data/award/Award.java | 5 +- .../backend/data/award/AwardInstance.java | 2 +- .../modgarden/backend/data/event/Event.java | 3 +- .../modgarden/backend/data/event/Project.java | 7 +- .../backend/data/event/Submission.java | 11 +- .../backend/data/fixer/fix/V5ToV6.java | 59 +- .../data/profile/MinecraftAccount.java | 102 --- .../modgarden/backend/data/profile/User.java | 298 --------- .../net/modgarden/backend/data/user/User.java | 68 ++ .../user/integration/DiscordIntegration.java | 16 + .../integration/MinecraftIntegration.java | 18 + .../user/integration/ModrinthIntegration.java | 16 + .../backend/endpoint/AuthorizedEndpoint.java | 4 +- .../modgarden/backend/endpoint/Endpoint.java | 20 +- .../backend/endpoint/EndpointPath.java | 12 + .../backend/endpoint/v2/AuthEndpoint.java | 4 +- .../endpoint/v2/auth/GenerateKeyEndpoint.java | 188 +++++- .../handler/v1/RegistrationHandler.java | 128 ---- .../v1/discord/DiscordBotLinkHandler.java | 141 ----- .../v1/discord/DiscordBotOAuthHandler.java | 459 -------------- .../v1/discord/DiscordBotProfileHandler.java | 305 --------- .../discord/DiscordBotSubmissionHandler.java | 597 ------------------ .../DiscordBotTeamManagementHandler.java | 315 --------- .../v1/discord/DiscordBotUnlinkHandler.java | 83 --- .../net/modgarden/backend/util/UuidUtils.java | 20 + 31 files changed, 551 insertions(+), 2568 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/data/Integration.java create mode 100644 src/main/java/net/modgarden/backend/data/Permissions.java delete mode 100644 src/main/java/net/modgarden/backend/data/profile/MinecraftAccount.java delete mode 100644 src/main/java/net/modgarden/backend/data/profile/User.java create mode 100644 src/main/java/net/modgarden/backend/data/user/User.java create mode 100644 src/main/java/net/modgarden/backend/data/user/integration/DiscordIntegration.java create mode 100644 src/main/java/net/modgarden/backend/data/user/integration/MinecraftIntegration.java create mode 100644 src/main/java/net/modgarden/backend/data/user/integration/ModrinthIntegration.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/EndpointPath.java delete mode 100644 src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java delete mode 100644 src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java delete mode 100644 src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java delete mode 100644 src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java delete mode 100644 src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java delete mode 100644 src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotTeamManagementHandler.java delete mode 100644 src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java create mode 100644 src/main/java/net/modgarden/backend/util/UuidUtils.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index bf31011..12e62fd 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; @@ -21,12 +20,9 @@ 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.data.user.User; import net.modgarden.backend.endpoint.Endpoint; import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; -import net.modgarden.backend.handler.v1.discord.*; -import net.modgarden.backend.handler.v1.RegistrationHandler; import net.modgarden.backend.util.AuthUtil; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -59,8 +55,6 @@ public class ModGardenBackend { public static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); - public static final String SAFE_URL_REGEX = "[a-zA-Z0-9!@$()`.+,_\"-]+"; - private static ModGardenBackend backend; private final Javalin app; @@ -96,32 +90,21 @@ public static void main(String[] args) { 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); + CODEC_REGISTRY.put(GenerateKeyEndpoint.Request.class, GenerateKeyEndpoint.Request.CODEC); + CODEC_REGISTRY.put(GenerateKeyEndpoint.Response.class, GenerateKeyEndpoint.Response.CODEC); Landing.createInstance(); AuthUtil.clearTokensEachFifteenMinutes(); - DiscordBotTeamManagementHandler.clearInvitesEachDay(); Javalin app = Javalin.create(config -> config.jsonMapper(createDFUMapper())); app.get("", Landing::getLandingJson); backend = new ModGardenBackend(app); - backend.v1(); + backend.v2(); app.error(400, BackendError::handleError); app.error(401, BackendError::handleError); @@ -133,82 +116,23 @@ public static void main(String[] args) { LOG.info("Mod Garden Backend Started!"); } - public void v1() { - get1("award/{award}", Award::getAwardType); - - get1("event/{event}", Event::getEvent); - get1("event/{event}/submissions", Submission::getSubmissionsByEvent); - - get1("events", Event::getEvents); - get1("events/current/registration", Event::getCurrentRegistrationEvent); - get1("events/current/development", Event::getCurrentDevelopmentEvent); - get1("events/current/prefreeze", Event::getCurrentPreFreezeEvent); - get1("events/active", Event::getActiveEvents); - - get1("mcaccount/{mcaccount}", MinecraftAccount::getAccount); - - get1("project/{project}", Project::getProject); - - get1("submission/{submission}", Submission::getSubmission); - - get1("user/{user}", User::getUser); - get1("user/{user}/projects", Project::getProjectsByUser); - get1("user/{user}/submissions", Submission::getSubmissionsByUser); - get1("user/{user}/submissions/{event}", Submission::getSubmissionsByUserAndEvent); - get1("user/{user}/awards", Award::getAwardsByUser); - - post1("discord/register", RegistrationHandler::discordBotRegister); - - get1("discord/oauth/modrinth", DiscordBotOAuthHandler::authModrinthAccount); - get1("discord/oauth/minecraft", DiscordBotOAuthHandler::authMinecraftAccount); - get1("discord/oauth/minecraft/challenge", DiscordBotOAuthHandler::getMicrosoftCodeChallenge); - - post1("discord/submission/create/modrinth", DiscordBotSubmissionHandler::submitModrinth); - post1("discord/submission/modify/version/modrinth", DiscordBotSubmissionHandler::setVersionModrinth); - post1("discord/submission/delete", DiscordBotSubmissionHandler::unsubmit); - - post1("discord/link", DiscordBotLinkHandler::link); - post1("discord/unlink", DiscordBotUnlinkHandler::unlink); - - post1("discord/modify/username", DiscordBotProfileHandler::modifyUsername); - post1("discord/modify/displayname", DiscordBotProfileHandler::modifyDisplayName); - post1("discord/modify/pronouns", DiscordBotProfileHandler::modifyPronouns); - post1("discord/modify/avatar", DiscordBotProfileHandler::modifyAvatarUrl); - - post1("discord/remove/pronouns", DiscordBotProfileHandler::removePronouns); - post1("discord/remove/avatar", DiscordBotProfileHandler::removeAvatarUrl); - - post1("discord/project/user/invite", DiscordBotTeamManagementHandler::sendInvite); - post1("discord/project/user/accept", DiscordBotTeamManagementHandler::acceptInvite); - post1("discord/project/user/decline", DiscordBotTeamManagementHandler::declineInvite); - post1("discord/project/user/remove", DiscordBotTeamManagementHandler::removeMember); - } - public void v2() { - post2(GenerateKeyEndpoint::new); - } - - private void get1(String endpoint, Handler consumer) { - this.app.get("/v1/" + endpoint, consumer); + post(GenerateKeyEndpoint::new); } - private void post1(String endpoint, Handler consumer) { - this.app.post("/v1/" + endpoint, consumer); - } - - private void get2(Supplier endpointSupplier) { + private void get(Supplier endpointSupplier) { Endpoint endpoint = endpointSupplier.get(); - this.app.get("/v2/" + endpoint.getPath(), endpoint); + this.app.get(endpoint.getPath(), endpoint); } - private void post2(Supplier endpointSupplier) { + private void post(Supplier endpointSupplier) { Endpoint endpoint = endpointSupplier.get(); - this.app.post("/v2/" + endpoint.getPath(), endpoint); + this.app.post(endpoint.getPath(), endpoint); } - private void put2(Supplier endpointSupplier) { + private void put(Supplier endpointSupplier) { Endpoint endpoint = endpointSupplier.get(); - this.app.put("/v2/" + endpoint.getPath(), endpoint); + this.app.put(endpoint.getPath(), endpoint); } public static Connection createDatabaseConnection() throws SQLException { @@ -223,15 +147,34 @@ private static void createDatabaseContents() { 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, 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, @@ -245,7 +188,7 @@ PRIMARY KEY (uuid) statement.addBatch(""" CREATE TABLE IF NOT EXISTS api_key_scopes ( uuid BLOB NOT NULL, - scope TEXT CHECK (scope in ('PROJECT', 'USER')), + 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, 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/Integration.java b/src/main/java/net/modgarden/backend/data/Integration.java new file mode 100644 index 0000000..1848d47 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/Integration.java @@ -0,0 +1,15 @@ +package net.modgarden.backend.data; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; + +public interface Integration { + Codec getCodec(); + + static Codec fromCodec(Codec codec) { + return codec.flatComapMap( + t -> t, + _ -> DataResult.error(() -> "Cannot safely convert from a typed integration to a generic integration.") + ); + } +} diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index 2ddabcc..1a191d6 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -10,16 +10,27 @@ // 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. - */ + /// 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_PROFILES(0x2, "edit_profiles", GLOBAL), - MODERATE_USERS(0x4, "moderate_users", GLOBAL), - EDIT_PROJECTS(0x8, "edit_projects", GLOBAL), - MODERATE_PROJECTS(0x10, "moderate_projects", GLOBAL), - UPLOAD_TO_CDN(0x20, "upload_to_cdn", GLOBAL),; + /// 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", USER), + /// Upload files to the CDN. + UPLOAD_TO_CDN(0x20, "upload_to_cdn", 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 { @@ -28,8 +39,12 @@ public enum Permission { return DataResult.error(() -> "Could not find permission '" + string + "'"); } }, permission -> DataResult.success(permission.name)); - public static final Codec> GLOBAL_LIST_CODEC = Codec.withAlternative(CODEC.listOf(), Codec.LONG.xmap(l -> fromLong(l, GLOBAL), Permission::toLong)); - public static final Codec> PROJECT_LIST_CODEC = Codec.withAlternative(CODEC.listOf(), Codec.LONG.xmap(l -> fromLong(l, PROJECT), 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::getBits); + public static final Codec STRING_PERMISSIONS_CODEC = Codec.STRING.xmap(Permissions::new, Permissions::toString); private final long bit; private final String name; @@ -51,6 +66,10 @@ public static List fromLong(long value, PermissionKind kind) { return permissions; } + public static List fromLongString(String value, PermissionKind kind) { + return fromLong(Long.parseLong(value), kind); + } + public static long toLong(List permissions) { long value = 0; for (Permission permission : permissions) { @@ -59,6 +78,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; diff --git a/src/main/java/net/modgarden/backend/data/PermissionKind.java b/src/main/java/net/modgarden/backend/data/PermissionKind.java index ae5dd41..a116929 100644 --- a/src/main/java/net/modgarden/backend/data/PermissionKind.java +++ b/src/main/java/net/modgarden/backend/data/PermissionKind.java @@ -2,6 +2,6 @@ public enum PermissionKind { ALL, - GLOBAL, + USER, PROJECT, } 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..723c77b --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/Permissions.java @@ -0,0 +1,44 @@ +package net.modgarden.backend.data; + +import java.util.List; + +/// A bitfield of permissions that uses the [Permission] system. +public final class Permissions { + private long bits; + + public Permissions(long bits) { + this.bits = bits; + } + + public Permissions(Permission... permissions) { + this.bits = Permission.toLong(List.of(permissions)); + } + + public Permissions(String bitsString) { + this.bits = Long.parseLong(bitsString); + } + + public void grantPermission(Permission permission) { + this.bits = Permission.grantPermission(this.bits, permission); + } + + public void revokePermission(Permission permission) { + this.bits = Permission.revokePermission(this.bits, permission); + } + + public boolean hasPermission(Permission permission) { + return Permission.hasPermission(this.bits, permission); + } + + public void and(long bits) { + this.bits &= bits; + } + + public long getBits() { + return this.bits; + } + + public String toString() { + return Long.toString(this.bits); + } +} 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 3e189b5..2f87a86 100644 --- a/src/main/java/net/modgarden/backend/data/award/Award.java +++ b/src/main/java/net/modgarden/backend/data/award/Award.java @@ -8,6 +8,7 @@ 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; @@ -34,7 +35,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,7 +78,7 @@ 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; 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..2c5f784 100644 --- a/src/main/java/net/modgarden/backend/data/award/AwardInstance.java +++ b/src/main/java/net/modgarden/backend/data/award/AwardInstance.java @@ -3,7 +3,7 @@ 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, 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 21b2bff..bbf610c 100644 --- a/src/main/java/net/modgarden/backend/data/event/Event.java +++ b/src/main/java/net/modgarden/backend/data/event/Event.java @@ -8,6 +8,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.endpoint.Endpoint; import net.modgarden.backend.util.ExtraCodecs; import org.jetbrains.annotations.Nullable; @@ -51,7 +52,7 @@ public record Event(String id, public static void getEvent(Context ctx) { String path = ctx.pathParam("event"); - 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; 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 4b0f3eb..4243501 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -7,7 +7,8 @@ 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.data.user.User; +import net.modgarden.backend.endpoint.Endpoint; import java.sql.Connection; import java.sql.PreparedStatement; @@ -33,7 +34,7 @@ public record Project(String id, public static void getProject(Context ctx) { String path = ctx.pathParam("project"); - 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; @@ -106,7 +107,7 @@ public static Project queryFromId(String id) { public static void getProjectsByUser(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; 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 36b181b..e9c6939 100644 --- a/src/main/java/net/modgarden/backend/data/event/Submission.java +++ b/src/main/java/net/modgarden/backend/data/event/Submission.java @@ -8,6 +8,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.endpoint.Endpoint; import net.modgarden.backend.util.ExtraCodecs; import java.sql.Connection; @@ -36,7 +37,7 @@ public record Submission(String id, public static void getSubmission(Context ctx) { String path = ctx.pathParam("submission"); - 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; @@ -55,7 +56,7 @@ public static void getSubmission(Context ctx) { public static void getSubmissionsByUser(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; @@ -90,7 +91,7 @@ public static void getSubmissionsByUser(Context ctx) { public static void getSubmissionsByEvent(Context ctx) { String event = ctx.pathParam("event"); - if (!event.matches(ModGardenBackend.SAFE_URL_REGEX)) { + if (!event.matches(Endpoint.SAFE_URL_REGEX)) { ctx.result("Illegal characters in path '" + event + "'."); ctx.status(422); return; @@ -126,12 +127,12 @@ public static void getSubmissionsByEvent(Context ctx) { public static void getSubmissionsByUserAndEvent(Context ctx) { String user = ctx.pathParam("user"); String event = ctx.pathParam("event"); - 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; } - if (!event.matches(ModGardenBackend.SAFE_URL_REGEX)) { + if (!event.matches(Endpoint.SAFE_URL_REGEX)) { ctx.result("Illegal characters in path '" + event + "'."); ctx.status(422); return; 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 index 91c8e1b..9eedb9f 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -68,17 +68,43 @@ protected void xFunc() throws SQLException { 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, created INTEGER NOT NULL, permissions INTEGER NOT NULL, PRIMARY KEY(id) ) """); statement.addBatch(""" - INSERT INTO users (id, username, display_name, pronouns, avatar_url, created, permissions) - SELECT id, username, display_name, pronouns, avatar_url, created, permissions from users_old + 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 """); @@ -144,7 +170,7 @@ PRIMARY KEY (uuid) statement.addBatch(""" CREATE TABLE IF NOT EXISTS api_key_scopes ( uuid BLOB NOT NULL, - scope TEXT CHECK (scope in ('project', 'user')), + 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, @@ -361,16 +387,31 @@ WITH cnt(i) AS ( statement.addBatch(""" UPDATE users - SET id = 'mgacc', permissions = 1, pronouns = 'they/it' + 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', 'GardenBot', 'it/its', NULL, unix_millis(), 1) + 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', 'Tiny Pineapple', 'it/its', NULL, unix_millis(), 0) + 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(""" 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 bb1c4c4..0000000 --- a/src/main/java/net/modgarden/backend/data/profile/User.java +++ /dev/null @@ -1,298 +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 io.javalin.http.Context; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.Permission; -import net.modgarden.backend.data.PermissionKind; -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 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.GLOBAL_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"), PermissionKind.GLOBAL) - ); - } 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..fb0d380 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/user/User.java @@ -0,0 +1,68 @@ +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.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.ZonedDateTime; +import java.util.*; + +import static java.util.Map.entry; +import static net.modgarden.backend.data.Integration.fromCodec; + +public record User( + String id, + String username, + ZonedDateTime created, + List projects, + List events, + List awards, + List permissions, + Map integrations +) { + public static final String USERNAME_REGEX = "^(?=.{3,32}$)[a-z_0-9]+?$"; + 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.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), + AwardInstance.UserValues.CODEC.listOf().fieldOf("awards").forGetter(User::awards), + Permission.GLOBAL_LIST_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/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index ca7222b..012da77 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -18,8 +18,8 @@ public abstract class AuthorizedEndpoint extends Endpoint { Argon2Factory.createAdvanced(Argon2Factory.Argon2Types.ARGON2id); private static final Argon2Version ARGON_2_VERSION = Argon2Version.V13; - public AuthorizedEndpoint(String path) { - super(path); + public AuthorizedEndpoint(int version, String path) { + super(version, path); } public static String generateRandomToken() { diff --git a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java index a2da38d..fdc3208 100644 --- a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java @@ -9,15 +9,26 @@ 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; - public Endpoint(String path) { - this.path = path; + public Endpoint(int version, String path) { + this.path = "/v" + version + "/" + path; } @Override public 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; + } + } } public String getPath() { @@ -27,4 +38,9 @@ public String getPath() { protected Connection getDatabaseConnection() throws SQLException { return ModGardenBackend.createDatabaseConnection(); } + + protected void invalidBody(Context ctx, String message) { + ctx.status(400); + ctx.result("Invalid body: " + message); + } } 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/v2/AuthEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java index aa36917..308b7bd 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java @@ -2,11 +2,13 @@ import io.javalin.http.Context; 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) { - super("auth/" + path); + super(2, "auth/" + path); } @Override 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 index 4ac55aa..19f35c1 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -1,14 +1,30 @@ package net.modgarden.backend.endpoint.v2.auth; +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 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.Permissions; +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 javax.sql.rowset.serial.SerialBlob; import java.nio.charset.StandardCharsets; +import java.sql.ResultSet; import java.time.Duration; import java.time.Instant; +import java.util.*; +import static java.util.Map.entry; + +@EndpointPath("/v2/auth/generate_key") public class GenerateKeyEndpoint extends AuthEndpoint { public GenerateKeyEndpoint() { super("generate_key"); @@ -16,20 +32,172 @@ public GenerateKeyEndpoint() { @Override public void handle(@NotNull Context ctx, String userId) throws Exception { - super.handle(ctx); + DataResult, JsonElement>> requestResult = Request.CODEC.decode(JsonOps.INSTANCE, JsonParser.parseString(ctx.body())); + + if (requestResult.isError()) { + //noinspection OptionalGetWithoutIsPresent + this.invalidBody(ctx, requestResult.error().get().message()); + } + Request request; + try { + request = requestResult.getOrThrow().getFirst(); + } catch (IllegalStateException e) { + this.invalidBody(ctx, e.getMessage()); + return; + } + + byte[] uuid = UuidUtils.randomBytes(); + String apiKey = AuthEndpoint.generateAPIKey(); + HashedSecret hashedSecret = + AuthEndpoint.hashSecret(apiKey.getBytes(StandardCharsets.UTF_8)); + + Permissions permissions = request.permissions(); + String projectId = null; + if (request.projectId().isPresent()) { + projectId = request.projectId().get(); + } + + if (projectId != null) { + try ( + var connection = this.getDatabaseConnection(); + var permissionStatement = connection.prepareStatement("SELECT id FROM projects WHERE id = ?") + ) { + permissionStatement.setString(1, projectId); + ResultSet resultSet = permissionStatement.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 = ?") + ) { + permissionStatement.setString(1, userId); + ResultSet resultSet = permissionStatement.executeQuery(); + permissions.and(resultSet.getLong("permissions")); + } + } + case "user" -> { + try ( + var connection = this.getDatabaseConnection(); + var permissionStatement = connection.prepareStatement("SELECT permissions FROM users WHERE id = ?") + ) { + permissionStatement.setString(1, userId); + ResultSet resultSet = permissionStatement.executeQuery(); + permissions.and( + Permission.DEFAULT_USER_PERMISSIONS.getBits() | resultSet.getLong("permissions")); + } + } + } try ( var connection = this.getDatabaseConnection(); - var apiKeyStatement = connection.prepareStatement("INSERT INTO api_keys(user_id, salt, hash, expires) VALUES (?, ?, ?, ?)") + var apiKeyStatement = connection.prepareStatement("INSERT INTO api_keys(uuid, user_id, salt, hash, expires) VALUES (?, ?, ?, ?, ?)") ) { - String apiKey = AuthEndpoint.generateAPIKey(); - HashedSecret hashedSecret = - AuthEndpoint.hashSecret(apiKey.getBytes(StandardCharsets.UTF_8)); - apiKeyStatement.setString(1, userId); - apiKeyStatement.setBlob(2, new SerialBlob(hashedSecret.salt())); - apiKeyStatement.setBlob(3, new SerialBlob(hashedSecret.hash())); - apiKeyStatement.setLong(4, Instant.now().plus(Duration.ofDays(365)).getEpochSecond()); + apiKeyStatement.setBytes(1, uuid); + apiKeyStatement.setString(2, userId); + apiKeyStatement.setBytes(3, hashedSecret.salt()); + apiKeyStatement.setBytes(4, hashedSecret.hash()); + apiKeyStatement.setLong(5, Instant.now().plus(Duration.ofDays(365)).toEpochMilli()); 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, request.permissions().getBits()); + apiKeyScopeStatement.execute(); + } + + ctx.json(new Response(apiKey)); + 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 + ) { + public static final Codec> CODEC = Scope.CODEC.xmap( + scope -> { + if (scope instanceof ProjectScope(String id, Permissions permissions)) { + return new Request<>(ProjectScope.TYPE, Optional.of(id), permissions); + } else if (scope instanceof UserScope(Permissions permissions)) { + return new Request<>(UserScope.TYPE, Optional.empty(), permissions); + } 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()); + } else { + return new UserScope(request.permissions()); + } + } + ); + } + + public record Response(String apiKey) { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("api_key").forGetter(Response::apiKey) + ).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) 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) + ).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) implements Scope { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( + Permission.STRING_PERMISSIONS_CODEC.fieldOf("permissions").forGetter(UserScope::permissions) + ).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/handler/v1/RegistrationHandler.java b/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java deleted file mode 100644 index 284a060..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/RegistrationHandler.java +++ /dev/null @@ -1,128 +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.NaturalId; -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; - } - - insertStatement.setString(1, NaturalId.generate("users", "id", 5)); - 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 ff4d562..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java +++ /dev/null @@ -1,597 +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.NaturalId; -import net.modgarden.backend.data.event.Event; -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) { - projectId = NaturalId.generate("projects", "id", 5); - 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(); - } - - String submissionId = NaturalId.generate("submissions", "id", - 5 - ); - 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 5bdf0db..0000000 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotTeamManagementHandler.java +++ /dev/null @@ -1,315 +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.endpoint.AuthorizedEndpoint; -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 = AuthorizedEndpoint.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/util/UuidUtils.java b/src/main/java/net/modgarden/backend/util/UuidUtils.java new file mode 100644 index 0000000..a72743d --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/UuidUtils.java @@ -0,0 +1,20 @@ +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 byte[] randomBytes() { + return toBytes(UUID.randomUUID()); + } +} From 14ec0e0b4de610940c400fe48194d1020a804db1 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 7 Oct 2025 00:32:11 -0400 Subject: [PATCH 28/98] feat: API key authentication --- .../modgarden/backend/HypertextResult.java | 52 ++++ .../modgarden/backend/ModGardenBackend.java | 32 ++- .../net/modgarden/backend/data/NaturalId.java | 23 +- .../modgarden/backend/data/Permission.java | 22 +- .../backend/data/PermissionKind.java | 7 - .../backend/data/PermissionScope.java | 20 ++ .../modgarden/backend/data/Permissions.java | 50 ++-- .../backend/data/fixer/fix/V5ToV6.java | 12 +- .../net/modgarden/backend/data/user/User.java | 11 +- .../backend/database/DatabaseAccess.java | 46 ++++ .../backend/endpoint/AuthorizedEndpoint.java | 258 +++++++++++++++--- .../modgarden/backend/endpoint/Endpoint.java | 38 ++- .../backend/endpoint/EndpointMethod.java | 17 ++ .../backend/endpoint/v2/AuthEndpoint.java | 8 +- .../endpoint/v2/auth/DeleteKeyEndpoint.java | 60 ++++ .../endpoint/v2/auth/GenerateKeyEndpoint.java | 111 ++++---- .../endpoint/v2/auth/ListKeysEndpoint.java | 94 +++++++ .../modgarden/backend/util/ExtraCodecs.java | 12 +- .../net/modgarden/backend/util/UuidUtils.java | 7 + 19 files changed, 714 insertions(+), 166 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/HypertextResult.java delete mode 100644 src/main/java/net/modgarden/backend/data/PermissionKind.java create mode 100644 src/main/java/net/modgarden/backend/data/PermissionScope.java create mode 100644 src/main/java/net/modgarden/backend/database/DatabaseAccess.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/EndpointMethod.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java 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 12e62fd..b4956bd 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -22,7 +22,9 @@ import net.modgarden.backend.data.fixer.DatabaseFixer; import net.modgarden.backend.data.user.User; 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.util.AuthUtil; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -43,6 +45,7 @@ import java.time.Instant; import java.util.HashMap; import java.util.Map; +import java.util.Properties; import java.util.function.Supplier; public class ModGardenBackend { @@ -96,6 +99,7 @@ public static void main(String[] args) { CODEC_REGISTRY.put(AwardInstance.FullAwardData.class, AwardInstance.FullAwardData.CODEC); CODEC_REGISTRY.put(GenerateKeyEndpoint.Request.class, GenerateKeyEndpoint.Request.CODEC); CODEC_REGISTRY.put(GenerateKeyEndpoint.Response.class, GenerateKeyEndpoint.Response.CODEC); + CODEC_REGISTRY.put(ListKeysEndpoint.Response.class, ListKeysEndpoint.Response.CODEC); Landing.createInstance(); AuthUtil.clearTokensEachFifteenMinutes(); @@ -108,6 +112,7 @@ public static void main(String[] args) { 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); @@ -118,6 +123,8 @@ public static void main(String[] args) { public void v2() { post(GenerateKeyEndpoint::new); + delete(DeleteKeyEndpoint::new); + get(ListKeysEndpoint::new); } private void get(Supplier endpointSupplier) { @@ -135,9 +142,16 @@ private void put(Supplier endpointSupplier) { this.app.put(endpoint.getPath(), endpoint); } + private void delete(Supplier endpointSupplier) { + Endpoint endpoint = endpointSupplier.get(); + this.app.delete(endpoint.getPath(), endpoint); + } + public static Connection createDatabaseConnection() throws SQLException { String url = "jdbc:sqlite:database.db"; - return DriverManager.getConnection(url); + Properties props = new Properties(); + props.setProperty("foreign_keys", "true"); + return DriverManager.getConnection(url, props); } private static void createDatabaseContents() { @@ -178,10 +192,10 @@ CREATE UNIQUE INDEX idx_user_id_field_name ON user_bio_fields(field_name, field_ CREATE TABLE IF NOT EXISTS api_keys ( uuid BLOB NOT NULL, user_id TEXT NOT NULL, - salt BLOB NOT NULL, - hash BLOB NOT NULL, + hash TEXT NOT NULL, expires INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id), + name TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (uuid) ) """); @@ -199,8 +213,7 @@ PRIMARY KEY (uuid) statement.addBatch(""" CREATE TABLE IF NOT EXISTS passwords ( user_id TEXT NOT NULL, - salt BLOB NOT NULL, - hash BLOB 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) @@ -347,9 +360,10 @@ PRIMARY KEY (code) @Override protected void xFunc() throws SQLException { String table = this.value_text(0); - String key = this .value_text(1); - int length = this.value_int(2); - this.result(NaturalId.generate(table, key, length)); + 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)); } } ); diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index e426d9d..67b5fd6 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -4,6 +4,8 @@ import org.jetbrains.annotations.NotNull; 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; @@ -18,6 +20,7 @@ public final class NaturalId { 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() {} @@ -67,19 +70,33 @@ private static String generateFromNumberRecursive(int number) { } @NotNull - public static String generate(String table, String key, int length) throws SQLException { + 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); - var exists = connection1.prepareStatement("SELECT true FROM " + table + " WHERE ? = ?"); + PreparedStatement exists; + if (key2 != null) { + exists = connection1.prepareStatement("SELECT true FROM " + table + " WHERE ? = ? OR ? = ?"); + } else { + exists = connection1.prepareStatement("SELECT true FROM " + table + " WHERE ? = ?"); + } exists.setString(1, key); exists.setString(2, naturalId); - if (!exists.execute() && !isReserved(naturalId)) { + if (key2 != null) { + exists.setString(3, key2); + exists.setString(4, naturalId); + } + ResultSet resultSet = exists.executeQuery(); + if (resultSet.isBeforeFirst() && !isReserved(naturalId)) { id = naturalId; } } } return 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 1a191d6..514eb77 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -6,7 +6,7 @@ import java.util.ArrayList; import java.util.List; -import static net.modgarden.backend.data.PermissionKind.*; +import static net.modgarden.backend.data.PermissionScope.*; // TODO: Add more user permissions for stuff. public enum Permission { @@ -22,7 +22,9 @@ public enum Permission { /// Edit others' projects and hide them. MODERATE_PROJECTS(0x10, "moderate_projects", USER), /// Upload files to the CDN. - UPLOAD_TO_CDN(0x20, "upload_to_cdn", USER),; + UPLOAD_TO_CDN(0x20, "upload_to_cdn", USER), + /// Generate and delete API apiKeys on behalf of this user or project. + MODIFY_API_KEY(0x40, "modify_api_key", ALL),; /// The default permissions that all users have. /// @@ -43,20 +45,20 @@ public enum Permission { 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::getBits); + 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 PermissionKind kind; + private final PermissionScope kind; - Permission(int bit, String name, PermissionKind kind) { + Permission(int bit, String name, PermissionScope kind) { this.bit = bit; this.name = name; this.kind = kind; } - public static List fromLong(long value, PermissionKind kind) { + public static List fromLong(long value, PermissionScope kind) { List permissions = new ArrayList<>(); for (Permission permission : Permission.values(kind)) { if (hasPermissionRaw(value, permission)) { @@ -66,7 +68,7 @@ public static List fromLong(long value, PermissionKind kind) { return permissions; } - public static List fromLongString(String value, PermissionKind kind) { + public static List fromLongString(String value, PermissionScope kind) { return fromLong(Long.parseLong(value), kind); } @@ -102,7 +104,7 @@ private static boolean hasPermissionRaw(long userPermissions, Permission permiss return (userPermissions & permission.bit) != 0; } - private static List values(PermissionKind kind) { + private static List values(PermissionScope kind) { List permissions = new ArrayList<>(); for (Permission permission : Permission.values()) { if (permission.kind == ALL || permission.kind == kind) { @@ -112,6 +114,10 @@ private static List values(PermissionKind kind) { return permissions; } + public long getBit() { + return this.bit; + } + public String getName() { return name; } diff --git a/src/main/java/net/modgarden/backend/data/PermissionKind.java b/src/main/java/net/modgarden/backend/data/PermissionKind.java deleted file mode 100644 index a116929..0000000 --- a/src/main/java/net/modgarden/backend/data/PermissionKind.java +++ /dev/null @@ -1,7 +0,0 @@ -package net.modgarden.backend.data; - -public enum PermissionKind { - ALL, - USER, - PROJECT, -} 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 index 723c77b..698b311 100644 --- a/src/main/java/net/modgarden/backend/data/Permissions.java +++ b/src/main/java/net/modgarden/backend/data/Permissions.java @@ -1,43 +1,57 @@ package net.modgarden.backend.data; +import org.jetbrains.annotations.NotNull; + import java.util.List; /// A bitfield of permissions that uses the [Permission] system. -public final class Permissions { - private long bits; +/// +/// 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(long bits) { - this.bits = bits; + public Permissions grantPermissions(Permissions permissions) { + return new Permissions(this.bits | permissions.bits); } - public Permissions(Permission... permissions) { - this.bits = Permission.toLong(List.of(permissions)); + public Permissions grantPermissions(Permission... permissions) { + return this.grantPermissions(new Permissions(permissions)); } - public Permissions(String bitsString) { - this.bits = Long.parseLong(bitsString); + public Permissions revokePermissions(Permissions permissions) { + return new Permissions(this.bits ^ permissions.bits); } - public void grantPermission(Permission permission) { - this.bits = Permission.grantPermission(this.bits, permission); + public Permissions revokePermissions(Permission... permissions) { + return this.revokePermissions(new Permissions(permissions)); } - public void revokePermission(Permission permission) { - this.bits = Permission.revokePermission(this.bits, permission); + public boolean hasPermissions(Permissions permissions) { + boolean hasPermissions = (permissions.bits & this.bits) == permissions.bits; + boolean hasAdministrator = hasAdministrator(this.bits); + return hasAdministrator || hasPermissions; } - public boolean hasPermission(Permission permission) { - return Permission.hasPermission(this.bits, permission); + public boolean hasPermissions(Permission... permissions) { + return this.hasPermissions(new Permissions(permissions)); } - public void and(long bits) { - this.bits &= bits; + /// Only allows permissions in [#bits] and ignores all other permissions. + public Permissions restrict(long bits) { + return new Permissions(this.bits & bits); } - public long getBits() { - return this.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/fixer/fix/V5ToV6.java b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java index 9eedb9f..60532e3 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -25,8 +25,9 @@ public V5ToV6() { protected void xFunc() throws SQLException { String table = this.value_text(0); String key = this .value_text(1); - int length = this.value_int(2); - this.result(NaturalId.generate(table, key, length)); + String key2 = this.value_text(2); + int length = this.value_int(3); + this.result(NaturalId.generate(table, key, key2, length)); } } ); @@ -160,9 +161,9 @@ INSERT INTO projects (id, slug) CREATE TABLE IF NOT EXISTS api_keys ( uuid BLOB NOT NULL, user_id TEXT NOT NULL, - salt BLOB NOT NULL, - hash BLOB UNIQUE 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) ) @@ -181,8 +182,7 @@ PRIMARY KEY (uuid) statement.addBatch(""" CREATE TABLE IF NOT EXISTS passwords ( user_id TEXT NOT NULL, - salt BLOB NOT NULL, - hash BLOB 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) diff --git a/src/main/java/net/modgarden/backend/data/user/User.java b/src/main/java/net/modgarden/backend/data/user/User.java index fb0d380..03e631c 100644 --- a/src/main/java/net/modgarden/backend/data/user/User.java +++ b/src/main/java/net/modgarden/backend/data/user/User.java @@ -6,6 +6,7 @@ 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; @@ -18,7 +19,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.time.ZonedDateTime; +import java.time.Instant; import java.util.*; import static java.util.Map.entry; @@ -27,11 +28,11 @@ public record User( String id, String username, - ZonedDateTime created, + Instant created, List projects, List events, List awards, - List permissions, + Permissions permissions, Map integrations ) { public static final String USERNAME_REGEX = "^(?=.{3,32}$)[a-z_0-9]+?$"; @@ -44,11 +45,11 @@ public record User( 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.ISO_DATE_TIME.fieldOf("created").forGetter(User::created), + 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.GLOBAL_LIST_CODEC.fieldOf("permissions").forGetter(User::permissions), + 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); 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..cbb0f80 --- /dev/null +++ b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java @@ -0,0 +1,46 @@ +package net.modgarden.backend.database; + +import net.modgarden.backend.HypertextResult; +import net.modgarden.backend.ModGardenBackend; +import net.modgarden.backend.data.Permissions; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class DatabaseAccess { + public Connection getDatabaseConnection() throws SQLException { + return ModGardenBackend.createDatabaseConnection(); + } + + 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 the specified project role."); + } + + return new HypertextResult<>(new Permissions(resultSet.getLong("permissions"))); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 012da77..46e4d32 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -1,25 +1,40 @@ package net.modgarden.backend.endpoint; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; import de.mkammerer.argon2.Argon2Advanced; import de.mkammerer.argon2.Argon2Factory; -import de.mkammerer.argon2.Argon2Version; 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 java.nio.charset.StandardCharsets; import java.security.SecureRandom; -import java.util.Base64; +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 static final Argon2Version ARGON_2_VERSION = Argon2Version.V13; + private final PermissionScope permissionScope; + private final boolean hasBody; + private final static String characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_+=[]{}|/?;:,.<>~"; - public AuthorizedEndpoint(int version, String path) { + public AuthorizedEndpoint(int version, String path, PermissionScope permissionScope, boolean hasBody) { super(version, path); + this.permissionScope = permissionScope; + this.hasBody = hasBody; } public static String generateRandomToken() { @@ -33,70 +48,223 @@ protected static String generateAPIKey() { /// Generate a secret (e.g. password) in [String] form. protected static String generateSecretString(int length) { - byte[] bytes = new byte[length]; - SECURE_RANDOM.nextBytes(bytes); - return new String(Base64.getEncoder().encode(bytes), StandardCharsets.UTF_8); + 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 HashedSecret hashSecret(byte[] bytes) { - byte[] salt = generateSalt(16); - // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id - byte[] hash = ARGON.rawHash( - 3, - 12288, + protected static String hashSecret(String secret) { + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + return ARGON.hash( + 2, + 19 * 1024, 1, - bytes, - salt + secret.toCharArray() ); - return new HashedSecret(salt, hash); } /// Verify that a secret (e.g. password) matches the given salt and hash. - protected static boolean verifySecret(HashedSecret hashedSecret, byte[] secret) { - return ARGON.verifyAdvanced( - 3, - 12228, - 1, - secret, - hashedSecret.salt(), - null, - null, - hashedSecret.hash().length, - ARGON_2_VERSION, - hashedSecret.hash() - ); - } - - protected static byte[] generateSalt(int length) { - if (length < 16) throw new IllegalArgumentException("A salt length < 16 is not strong enough!"); - byte[] bytes = new byte[length]; - SECURE_RANDOM.nextBytes(bytes); - return bytes; + protected static boolean verifySecret(String hash, String secret) { + return ARGON.verify(hash, secret.toCharArray()); } - protected abstract void handle(@NotNull Context ctx, String userId) throws Exception; + protected abstract void handle(@NotNull Context ctx, String userId, Permissions userPermissions) throws Exception; @Override public final void handle(@NotNull Context ctx) throws Exception { - if (!validateAuth(ctx)) { + ValidationResult validationResult = validateAuth(ctx); + if (!validationResult.authorized()) { return; } super.handle(ctx); - this.handle(ctx, "grbot"); // todo un-hardcode when we make proper user_id:password auth + this.handle(ctx, validationResult.userId(), validationResult.userPermissions()); } - private boolean validateAuth(Context ctx) { + /// # 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. + /// + /// **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 + /// and an ominous title. + /// + /// ### `2025-10-06` No Incidents! + /// Yay. + 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( - ctx.header("Authorization")); - if (!authorized) { + authorization); + Permissions userPermissions = new Permissions(); + // we know this is GardenBot. let it bypass everything + if (authorized) { + userPermissions = new Permissions(Permission.ADMINISTRATOR); + return new ValidationResult(true, "grbot", userPermissions); + } + + JsonObject body; + String projectId = null; + if (this.hasBody) { + try { + body = JsonParser.parseString(ctx.body()).getAsJsonObject(); + JsonElement projectIdElement = body.get("project_id"); + if (projectIdElement != null) { + projectId = projectIdElement.getAsString(); + } + } catch (JsonSyntaxException | IllegalStateException e) { + this.invalidBody(ctx, e.getMessage()); + } + } + + 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()) { + ctx.result("Forbidden."); + ctx.status(403); + return ValidationResult.no(); + } + + while (!authorized && apiKeyResult.next()) { + byte[] uuid = apiKeyResult.getBytes("uuid"); + apiKeyScopeStatement.setBytes(1, uuid); + ResultSet apiKeyScopeResult = apiKeyScopeStatement.executeQuery(); + if (!apiKeyScopeResult.isBeforeFirst()) { + ctx.result("Forbidden."); + ctx.status(403); + return ValidationResult.no(); + } + + // forbid expired apiKeys + if (Instant.now().isAfter(Instant.ofEpochMilli(apiKeyResult.getLong("expires")))) { + ctx.result("Unauthorized."); + ctx.status(401); + + // 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) { + userPermissions.grantPermissions(new Permissions(apiKeyScopeResult.getLong("permissions"))); + // Disallow permissions the user doesn't already have + switch (scope) { + case USER -> { + Permissions permissions = this.getDatabaseAccess() + .getUserPermissions(userId) + .unwrap(ctx); + if (permissions == null) return ValidationResult.no(); + userPermissions = permissions; + userPermissions = userPermissions.restrict(permissions.bits()); + } + case PROJECT -> { + Permissions permissions = this.getDatabaseAccess() + .getProjectPermissions(userId, projectId) + .unwrap(ctx); + if (permissions == null) return ValidationResult.no(); + userPermissions = permissions; + userPermissions = userPermissions.restrict(permissions.bits()); + } + } + } + } + } + } + if (!authorized && !ctx.status().isError()) { ctx.result("Unauthorized."); ctx.status(401); + return ValidationResult.no(); } - return authorized; + return new ValidationResult(authorized, userId, userPermissions); } - public record HashedSecret(byte[] salt, byte[] hash) {} + protected boolean requirePermissions(Context ctx, Permissions userPermissions, Permissions permissions) { + if (!userPermissions.hasPermissions(permissions)) { + ctx.status(403); + ctx.result("User lacks permission; required " + permissions); + return false; + } + + return true; + } + + protected boolean requirePermissions(Context ctx, Permissions userPermissions, Permission... permissions) { + return requirePermissions(ctx, userPermissions, new Permissions(permissions)); + } + + public record HashedSecret(byte[] salt, byte[] hash) { + @Override + public boolean equals(Object o) { + if (!(o instanceof HashedSecret)) return false; + if (this == o) return true; + return Arrays.equals(salt, ((HashedSecret) o).salt) && Arrays.equals(hash, ((HashedSecret) o).hash); + } + } + + private record ValidationResult(boolean authorized, String userId, Permissions userPermissions) { + 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 index fdc3208..c3be367 100644 --- a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java @@ -1,8 +1,18 @@ 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.ModGardenBackend; +import net.modgarden.backend.data.Permissions; +import net.modgarden.backend.database.DatabaseAccess; +import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; import org.jetbrains.annotations.NotNull; import java.sql.Connection; @@ -14,6 +24,7 @@ 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; @@ -35,12 +46,37 @@ public String getPath() { return path; } + protected DatabaseAccess getDatabaseAccess() { + return databaseAccess; + } + protected Connection getDatabaseConnection() throws SQLException { - return ModGardenBackend.createDatabaseConnection(); + 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/v2/AuthEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java index 308b7bd..ca1c4d4 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java @@ -1,16 +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) { - super(2, "auth/" + path); + public AuthEndpoint(String path, PermissionScope permissionScope, boolean hasBody) { + super(2, "auth/" + path, permissionScope, hasBody); } @Override - public abstract void handle(@NotNull Context ctx, String userId) throws Exception; + public abstract void handle(@NotNull Context ctx, String userId, Permissions userPermissions) 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..ebe177e --- /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 handle( + @NotNull Context ctx, + String userId, + Permissions userPermissions + ) throws Exception { + if (!this.requirePermissions(ctx, userPermissions, 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.requirePermissions(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 index 19f35c1..a80dc9f 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -1,55 +1,52 @@ package net.modgarden.backend.endpoint.v2.auth; -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 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.nio.charset.StandardCharsets; import java.sql.ResultSet; import java.time.Duration; import java.time.Instant; import java.util.*; import static java.util.Map.entry; +import static net.modgarden.backend.endpoint.EndpointMethod.Method.POST; -@EndpointPath("/v2/auth/generate_key") -public class GenerateKeyEndpoint extends AuthEndpoint { +@EndpointMethod(POST) +@EndpointPath("/v2/auth/api_key") +public final class GenerateKeyEndpoint extends AuthEndpoint { public GenerateKeyEndpoint() { - super("generate_key"); + super("api_key", PermissionScope.ALL, true); } @Override - public void handle(@NotNull Context ctx, String userId) throws Exception { - DataResult, JsonElement>> requestResult = Request.CODEC.decode(JsonOps.INSTANCE, JsonParser.parseString(ctx.body())); + public void handle(@NotNull Context ctx, String userId, Permissions userPermissions) throws Exception { + if (!this.requirePermissions(ctx, userPermissions, Permission.MODIFY_API_KEY)) return; - if (requestResult.isError()) { - //noinspection OptionalGetWithoutIsPresent - this.invalidBody(ctx, requestResult.error().get().message()); - } - Request request; - try { - request = requestResult.getOrThrow().getFirst(); - } catch (IllegalStateException e) { - this.invalidBody(ctx, e.getMessage()); + 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(); - HashedSecret hashedSecret = - AuthEndpoint.hashSecret(apiKey.getBytes(StandardCharsets.UTF_8)); + String hash = + AuthEndpoint.hashSecret(apiKey); Permissions permissions = request.permissions(); String projectId = null; @@ -60,10 +57,10 @@ public void handle(@NotNull Context ctx, String userId) throws Exception { if (projectId != null) { try ( var connection = this.getDatabaseConnection(); - var permissionStatement = connection.prepareStatement("SELECT id FROM projects WHERE id = ?") + var projectStatement = connection.prepareStatement("SELECT id FROM projects WHERE id = ?") ) { - permissionStatement.setString(1, projectId); - ResultSet resultSet = permissionStatement.executeQuery(); + projectStatement.setString(1, projectId); + ResultSet resultSet = projectStatement.executeQuery(); if (!resultSet.isBeforeFirst()) { ctx.status(404); ctx.result("Project with ID " + projectId + " does not exist"); @@ -76,35 +73,28 @@ public void handle(@NotNull Context ctx, String userId) throws Exception { case "project" -> { try ( var connection = this.getDatabaseConnection(); - var permissionStatement = connection.prepareStatement("SELECT permissions FROM project_roles WHERE user_id = ?") - ) { - permissionStatement.setString(1, userId); - ResultSet resultSet = permissionStatement.executeQuery(); - permissions.and(resultSet.getLong("permissions")); - } - } - case "user" -> { - try ( - var connection = this.getDatabaseConnection(); - var permissionStatement = connection.prepareStatement("SELECT permissions FROM users WHERE id = ?") + 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.and( - Permission.DEFAULT_USER_PERMISSIONS.getBits() | resultSet.getLong("permissions")); + permissions = permissions.restrict(resultSet.getLong("permissions")); + if (!this.requirePermissions(ctx, new Permissions(resultSet.getLong("permissions")), Permission.MODIFY_API_KEY)) return; } } + case "user" -> permissions = permissions.restrict( + Permission.DEFAULT_USER_PERMISSIONS.bits() | userPermissions.bits()); } try ( var connection = this.getDatabaseConnection(); - var apiKeyStatement = connection.prepareStatement("INSERT INTO api_keys(uuid, user_id, salt, hash, expires) VALUES (?, ?, ?, ?, ?)") + var apiKeyStatement = connection.prepareStatement("INSERT INTO api_keys(uuid, user_id, hash, expires, name) VALUES (?, ?, ?, ?, ?)") ) { apiKeyStatement.setBytes(1, uuid); apiKeyStatement.setString(2, userId); - apiKeyStatement.setBytes(3, hashedSecret.salt()); - apiKeyStatement.setBytes(4, hashedSecret.hash()); - apiKeyStatement.setLong(5, Instant.now().plus(Duration.ofDays(365)).toEpochMilli()); + apiKeyStatement.setString(3, hash); + apiKeyStatement.setLong(4, request.expires().toEpochMilli()); + apiKeyStatement.setString(5, request.name()); apiKeyStatement.execute(); } @@ -120,11 +110,11 @@ public void handle(@NotNull Context ctx, String userId) throws Exception { // actually what the hell lmao. what is this second integer? apiKeyScopeStatement.setNull(3, 0); } - apiKeyScopeStatement.setLong(4, request.permissions().getBits()); + apiKeyScopeStatement.setLong(4, permissions.bits()); apiKeyScopeStatement.execute(); } - ctx.json(new Response(apiKey)); + ctx.json(new Response(apiKey, UuidUtils.fromBytes(uuid))); ctx.status(200); } @@ -137,31 +127,34 @@ private interface Scope { public record Request( ScopeType scope, Optional projectId, - Permissions permissions + Permissions permissions, + Instant expires, + String name ) { public static final Codec> CODEC = Scope.CODEC.xmap( scope -> { - if (scope instanceof ProjectScope(String id, Permissions permissions)) { - return new Request<>(ProjectScope.TYPE, Optional.of(id), permissions); - } else if (scope instanceof UserScope(Permissions permissions)) { - return new Request<>(UserScope.TYPE, Optional.empty(), permissions); + 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()); + return new ProjectScope(request.projectId().get(), request.permissions(), request.expires(), request.name()); } else { - return new UserScope(request.permissions()); + return new UserScope(request.permissions(), request.expires(), request.name()); } } ); } - public record Response(String apiKey) { + 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) + Codec.STRING.fieldOf("api_key").forGetter(Response::apiKey), + ExtraCodecs.UUID_CODEC.fieldOf("uuid").forGetter(Response::uuid) ).apply(inst, Response::new)); } @@ -176,10 +169,12 @@ public MapCodec getCodec() { } } - private record ProjectScope(String projectId, Permissions permissions) implements Scope { + 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) + 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); @@ -189,9 +184,11 @@ public ScopeType getType() { } } - private record UserScope(Permissions permissions) implements Scope { + 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) + 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); 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..0c1e0dd --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java @@ -0,0 +1,94 @@ +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 handle( + @NotNull Context ctx, + String userId, + Permissions userPermissions + ) throws Exception { + 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("SELECT scope, project_id, permissions FROM api_key_scopes WHERE uuid = ?") + ) { + apiKeyStatement.setString(1, userId); + ResultSet resultSet = apiKeyStatement.executeQuery(); + while (resultSet.next()) { + UUID uuid = UuidUtils.fromBytes(resultSet.getBytes("uuid")); + apiKeyScopeStatement.setBytes(1, resultSet.getBytes("uuid")); + ResultSet scopeResult = apiKeyScopeStatement.executeQuery(); + if (!scopeResult.isBeforeFirst()) { + ctx.result("API key " + uuid + " has no scope."); + ctx.status(500); + return; + } + + 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/util/ExtraCodecs.java b/src/main/java/net/modgarden/backend/util/ExtraCodecs.java index 4b8b427..0070788 100644 --- a/src/main/java/net/modgarden/backend/util/ExtraCodecs.java +++ b/src/main/java/net/modgarden/backend/util/ExtraCodecs.java @@ -11,12 +11,16 @@ 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()) + ); } diff --git a/src/main/java/net/modgarden/backend/util/UuidUtils.java b/src/main/java/net/modgarden/backend/util/UuidUtils.java index a72743d..61636fd 100644 --- a/src/main/java/net/modgarden/backend/util/UuidUtils.java +++ b/src/main/java/net/modgarden/backend/util/UuidUtils.java @@ -14,6 +14,13 @@ public static byte[] toBytes(UUID uuid) { 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()); } From 0a464bb5932d79987096979df64f0c2035ffeca8 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 7 Oct 2025 00:59:29 -0400 Subject: [PATCH 29/98] feat: `project_id` query for `ListKeysEndpoint` --- .../endpoint/v2/auth/ListKeysEndpoint.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 index 0c1e0dd..d972c7d 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java @@ -35,22 +35,31 @@ public void handle( String userId, Permissions userPermissions ) 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("SELECT scope, project_id, permissions FROM api_key_scopes WHERE uuid = ?") + 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()) { - ctx.result("API key " + uuid + " has no scope."); - ctx.status(500); - return; + continue; } apiKeys.add(new ApiKey( From e946d6a7e88c0bce31f5517aa8e7384c96419a57 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 7 Oct 2025 01:30:04 -0400 Subject: [PATCH 30/98] docs: fix erroneous apiKeys -> keys --- src/main/java/net/modgarden/backend/data/Permission.java | 2 +- .../java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index 514eb77..5f6e388 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -23,7 +23,7 @@ public enum Permission { MODERATE_PROJECTS(0x10, "moderate_projects", USER), /// Upload files to the CDN. UPLOAD_TO_CDN(0x20, "upload_to_cdn", USER), - /// Generate and delete API apiKeys on behalf of this user or project. + /// Generate and delete API keys on behalf of this user or project. MODIFY_API_KEY(0x40, "modify_api_key", ALL),; /// The default permissions that all users have. diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 46e4d32..40ac30c 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -169,7 +169,7 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { return ValidationResult.no(); } - // forbid expired apiKeys + // forbid expired keys if (Instant.now().isAfter(Instant.ofEpochMilli(apiKeyResult.getLong("expires")))) { ctx.result("Unauthorized."); ctx.status(401); From 1fddf2c40a6aef0477ac16de538d94fbe6420162 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 7 Oct 2025 01:31:35 -0400 Subject: [PATCH 31/98] chore: bump version `2.0.0-beta.1` --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7c9f319ed56c80fc7fe0aaf4a26ba7a41d27587c Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 7 Oct 2025 19:03:01 -0400 Subject: [PATCH 32/98] docs: test your code disclaimer in AuthorizedEndpoint#validateAuth --- .../java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 40ac30c..a854bf7 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -92,6 +92,7 @@ public final void handle(@NotNull Context ctx) throws Exception { /// 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.** /// From bacdbb24711e22adcb8f052f2ce705f1609ac059 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Tue, 7 Oct 2025 19:53:26 -0400 Subject: [PATCH 33/98] refactor(auth): DRY the 403 and 401 messages --- .../backend/endpoint/AuthorizedEndpoint.java | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index a854bf7..660595b 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -155,8 +155,7 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { apiKeyStatement.setString(1, userId); ResultSet apiKeyResult = apiKeyStatement.executeQuery(); if (!apiKeyResult.isBeforeFirst()) { - ctx.result("Forbidden."); - ctx.status(403); + this.setStatusUnauthorized(ctx); return ValidationResult.no(); } @@ -165,15 +164,13 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { apiKeyScopeStatement.setBytes(1, uuid); ResultSet apiKeyScopeResult = apiKeyScopeStatement.executeQuery(); if (!apiKeyScopeResult.isBeforeFirst()) { - ctx.result("Forbidden."); - ctx.status(403); + this.setStatusUnauthorized(ctx); return ValidationResult.no(); } // forbid expired keys if (Instant.now().isAfter(Instant.ofEpochMilli(apiKeyResult.getLong("expires")))) { - ctx.result("Unauthorized."); - ctx.status(401); + this.setStatusUnauthorized(ctx); // remove expired key try ( @@ -232,14 +229,23 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { } } if (!authorized && !ctx.status().isError()) { - ctx.result("Unauthorized."); - ctx.status(401); + this.setStatusUnauthorized(ctx); return ValidationResult.no(); } return new ValidationResult(authorized, userId, userPermissions); } + protected void setStatusUnauthorized(Context ctx) { + ctx.result("Unauthorized."); + ctx.status(401); + } + + protected void setStatusForbidden(Context ctx) { + ctx.result("Forbidden."); + ctx.status(403); + } + protected boolean requirePermissions(Context ctx, Permissions userPermissions, Permissions permissions) { if (!userPermissions.hasPermissions(permissions)) { ctx.status(403); From 43e08de353842d1d19750d6f2324d97aef7168a9 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 8 Oct 2025 14:31:24 -0400 Subject: [PATCH 34/98] typo: misleading phrasing in project permissions 404 --- .../java/net/modgarden/backend/database/DatabaseAccess.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java index cbb0f80..19b1708 100644 --- a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java +++ b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java @@ -37,7 +37,7 @@ public HypertextResult getProjectPermissions(String userId, String userStatement.setString(2, projectId); ResultSet resultSet = userStatement.executeQuery(); if (!resultSet.isBeforeFirst()) { - return new HypertextResult<>(404, "User does not have the specified project role."); + return new HypertextResult<>(404, "User does not have a role in the specified project."); } return new HypertextResult<>(new Permissions(resultSet.getLong("permissions"))); From e7fc179365160ac38c31d57b23c7bc606c60f1b4 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 8 Oct 2025 14:31:51 -0400 Subject: [PATCH 35/98] chore: optimize imports --- src/main/java/net/modgarden/backend/ModGardenBackend.java | 6 +----- src/main/java/net/modgarden/backend/data/user/User.java | 3 ++- src/main/java/net/modgarden/backend/endpoint/Endpoint.java | 3 --- .../backend/endpoint/v2/auth/GenerateKeyEndpoint.java | 4 +++- .../modgarden/backend/oauth/client/OAuthClientSupplier.java | 2 -- src/main/java/net/modgarden/backend/util/ExtraCodecs.java | 2 -- 6 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index b4956bd..f6c434c 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -37,11 +37,7 @@ 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.time.Instant; import java.util.HashMap; import java.util.Map; diff --git a/src/main/java/net/modgarden/backend/data/user/User.java b/src/main/java/net/modgarden/backend/data/user/User.java index 03e631c..2868e78 100644 --- a/src/main/java/net/modgarden/backend/data/user/User.java +++ b/src/main/java/net/modgarden/backend/data/user/User.java @@ -20,7 +20,8 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; -import java.util.*; +import java.util.List; +import java.util.Map; import static java.util.Map.entry; import static net.modgarden.backend.data.Integration.fromCodec; diff --git a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java index c3be367..7268489 100644 --- a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java @@ -9,10 +9,7 @@ import io.javalin.http.Context; import io.javalin.http.Handler; import net.modgarden.backend.HypertextResult; -import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.data.Permissions; import net.modgarden.backend.database.DatabaseAccess; -import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; import org.jetbrains.annotations.NotNull; import java.sql.Connection; 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 index a80dc9f..a1dc417 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -17,7 +17,9 @@ import java.sql.ResultSet; import java.time.Duration; import java.time.Instant; -import java.util.*; +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; 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/ExtraCodecs.java b/src/main/java/net/modgarden/backend/util/ExtraCodecs.java index 0070788..b370023 100644 --- a/src/main/java/net/modgarden/backend/util/ExtraCodecs.java +++ b/src/main/java/net/modgarden/backend/util/ExtraCodecs.java @@ -2,9 +2,7 @@ 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; From 562cc8c12e1c4bd3efec15e487e1ecc5ade6be1e Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 8 Oct 2025 14:34:57 -0400 Subject: [PATCH 36/98] chore: remove unused stuff --- src/main/java/net/modgarden/backend/data/user/User.java | 1 - .../net/modgarden/backend/database/DatabaseAccess.java | 4 ++-- .../modgarden/backend/endpoint/AuthorizedEndpoint.java | 9 --------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/user/User.java b/src/main/java/net/modgarden/backend/data/user/User.java index 2868e78..e27604c 100644 --- a/src/main/java/net/modgarden/backend/data/user/User.java +++ b/src/main/java/net/modgarden/backend/data/user/User.java @@ -36,7 +36,6 @@ public record User( Permissions permissions, Map integrations ) { - public static final String USERNAME_REGEX = "^(?=.{3,32}$)[a-z_0-9]+?$"; private static final Map> INTEGRATION_CODECS = Map.ofEntries( entry("modrinth", fromCodec(ModrinthIntegration.CODEC)), entry("discord", fromCodec(DiscordIntegration.CODEC)), diff --git a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java index 19b1708..f8c78f2 100644 --- a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java +++ b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java @@ -16,7 +16,7 @@ public Connection getDatabaseConnection() throws SQLException { public HypertextResult getUserPermissions(String userId) throws SQLException { try ( var connection = getDatabaseConnection(); - var userStatement = connection.prepareStatement("SELECT permissions FROM users WHERE id = ?"); + var userStatement = connection.prepareStatement("SELECT permissions FROM users WHERE id = ?") ) { userStatement.setString(1, userId); ResultSet resultSet = userStatement.executeQuery(); @@ -31,7 +31,7 @@ public HypertextResult getUserPermissions(String userId) throws SQL 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 = ?"); + var userStatement = connection.prepareStatement("SELECT permissions FROM project_roles WHERE user_id = ? AND project_id = ?") ) { userStatement.setString(1, userId); userStatement.setString(2, projectId); diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 660595b..60ce030 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -260,15 +260,6 @@ protected boolean requirePermissions(Context ctx, Permissions userPermissions, P return requirePermissions(ctx, userPermissions, new Permissions(permissions)); } - public record HashedSecret(byte[] salt, byte[] hash) { - @Override - public boolean equals(Object o) { - if (!(o instanceof HashedSecret)) return false; - if (this == o) return true; - return Arrays.equals(salt, ((HashedSecret) o).salt) && Arrays.equals(hash, ((HashedSecret) o).hash); - } - } - private record ValidationResult(boolean authorized, String userId, Permissions userPermissions) { public static ValidationResult no() { return new ValidationResult(false, NaturalId.getMissingno(), new Permissions()); From 62169f255ae5bd44abba355f63c364ea365807fb Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 8 Oct 2025 14:47:15 -0400 Subject: [PATCH 37/98] fix: set status unauthorized when user permissions are not present --- .../modgarden/backend/endpoint/AuthorizedEndpoint.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 60ce030..47128ea 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -211,7 +211,10 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { Permissions permissions = this.getDatabaseAccess() .getUserPermissions(userId) .unwrap(ctx); - if (permissions == null) return ValidationResult.no(); + if (permissions == null) { + this.setStatusUnauthorized(ctx); + return ValidationResult.no(); + } userPermissions = permissions; userPermissions = userPermissions.restrict(permissions.bits()); } @@ -219,7 +222,10 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { Permissions permissions = this.getDatabaseAccess() .getProjectPermissions(userId, projectId) .unwrap(ctx); - if (permissions == null) return ValidationResult.no(); + if (permissions == null) { + this.setStatusUnauthorized(ctx); + return ValidationResult.no(); + } userPermissions = permissions; userPermissions = userPermissions.restrict(permissions.bits()); } From b45a28bf18eeb668c0f6ee7894e9ead657a4ad07 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 8 Oct 2025 15:50:36 -0400 Subject: [PATCH 38/98] style(auth): reduce confusion between scope, user, project, and requested permissions --- .../backend/endpoint/AuthorizedEndpoint.java | 40 +++++++++---------- .../backend/endpoint/v2/AuthEndpoint.java | 2 +- .../endpoint/v2/auth/DeleteKeyEndpoint.java | 4 +- .../endpoint/v2/auth/GenerateKeyEndpoint.java | 17 ++++---- .../endpoint/v2/auth/ListKeysEndpoint.java | 2 +- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 47128ea..b181574 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -72,7 +72,7 @@ protected static boolean verifySecret(String hash, String secret) { return ARGON.verify(hash, secret.toCharArray()); } - protected abstract void handle(@NotNull Context ctx, String userId, Permissions userPermissions) throws Exception; + protected abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; @Override public final void handle(@NotNull Context ctx) throws Exception { @@ -82,7 +82,7 @@ public final void handle(@NotNull Context ctx) throws Exception { } super.handle(ctx); - this.handle(ctx, validationResult.userId(), validationResult.userPermissions()); + this.handle(ctx, validationResult.userId(), validationResult.scopePermissions()); } /// # Caution @@ -113,11 +113,11 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { boolean authorized = ("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals( authorization); - Permissions userPermissions = new Permissions(); + Permissions scopePermissions = new Permissions(); // we know this is GardenBot. let it bypass everything if (authorized) { - userPermissions = new Permissions(Permission.ADMINISTRATOR); - return new ValidationResult(true, "grbot", userPermissions); + scopePermissions = new Permissions(Permission.ADMINISTRATOR); + return new ValidationResult(true, "grbot", scopePermissions); } JsonObject body; @@ -204,30 +204,30 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { // give this endpoint the permissions as specified by the API key if (authorized) { - userPermissions.grantPermissions(new Permissions(apiKeyScopeResult.getLong("permissions"))); + scopePermissions.grantPermissions(new Permissions(apiKeyScopeResult.getLong("permissions"))); // Disallow permissions the user doesn't already have switch (scope) { case USER -> { - Permissions permissions = this.getDatabaseAccess() + Permissions userPermissions = this.getDatabaseAccess() .getUserPermissions(userId) .unwrap(ctx); - if (permissions == null) { + if (userPermissions == null) { this.setStatusUnauthorized(ctx); return ValidationResult.no(); } - userPermissions = permissions; - userPermissions = userPermissions.restrict(permissions.bits()); + scopePermissions = userPermissions; + scopePermissions = scopePermissions.restrict(userPermissions.bits()); } case PROJECT -> { - Permissions permissions = this.getDatabaseAccess() + Permissions projectPermissions = this.getDatabaseAccess() .getProjectPermissions(userId, projectId) .unwrap(ctx); - if (permissions == null) { + if (projectPermissions == null) { this.setStatusUnauthorized(ctx); return ValidationResult.no(); } - userPermissions = permissions; - userPermissions = userPermissions.restrict(permissions.bits()); + scopePermissions = projectPermissions; + scopePermissions = scopePermissions.restrict(projectPermissions.bits()); } } } @@ -239,7 +239,7 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { return ValidationResult.no(); } - return new ValidationResult(authorized, userId, userPermissions); + return new ValidationResult(authorized, userId, scopePermissions); } protected void setStatusUnauthorized(Context ctx) { @@ -252,8 +252,8 @@ protected void setStatusForbidden(Context ctx) { ctx.status(403); } - protected boolean requirePermissions(Context ctx, Permissions userPermissions, Permissions permissions) { - if (!userPermissions.hasPermissions(permissions)) { + protected boolean requirePermissions(Context ctx, Permissions scopePermissions, Permissions permissions) { + if (!scopePermissions.hasPermissions(permissions)) { ctx.status(403); ctx.result("User lacks permission; required " + permissions); return false; @@ -262,11 +262,11 @@ protected boolean requirePermissions(Context ctx, Permissions userPermissions, P return true; } - protected boolean requirePermissions(Context ctx, Permissions userPermissions, Permission... permissions) { - return requirePermissions(ctx, userPermissions, new Permissions(permissions)); + protected boolean requirePermissions(Context ctx, Permissions scopePermissions, Permission... permissions) { + return requirePermissions(ctx, scopePermissions, new Permissions(permissions)); } - private record ValidationResult(boolean authorized, String userId, Permissions userPermissions) { + 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/v2/AuthEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java index ca1c4d4..8b48e32 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java @@ -14,5 +14,5 @@ public AuthEndpoint(String path, PermissionScope permissionScope, boolean hasBod } @Override - public abstract void handle(@NotNull Context ctx, String userId, Permissions userPermissions) throws Exception; + public abstract void handle(@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 index ebe177e..2157251 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java @@ -26,9 +26,9 @@ public DeleteKeyEndpoint() { public void handle( @NotNull Context ctx, String userId, - Permissions userPermissions + Permissions scopePermissions ) throws Exception { - if (!this.requirePermissions(ctx, userPermissions, Permission.MODIFY_API_KEY)) return; + if (!this.requirePermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; UUID uuid = UUID.fromString(ctx.pathParam("uuid")); 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 index a1dc417..6f5d60f 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -32,8 +32,8 @@ public GenerateKeyEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions userPermissions) throws Exception { - if (!this.requirePermissions(ctx, userPermissions, Permission.MODIFY_API_KEY)) return; + public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + if (!this.requirePermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; Request request = this.decodeBody(ctx, Request.CODEC) .unwrap(ctx); @@ -50,7 +50,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions userPermissi String hash = AuthEndpoint.hashSecret(apiKey); - Permissions permissions = request.permissions(); + Permissions requestedPermissions = request.permissions(); String projectId = null; if (request.projectId().isPresent()) { projectId = request.projectId().get(); @@ -80,12 +80,13 @@ public void handle(@NotNull Context ctx, String userId, Permissions userPermissi permissionStatement.setString(1, userId); permissionStatement.setString(2, projectId); ResultSet resultSet = permissionStatement.executeQuery(); - permissions = permissions.restrict(resultSet.getLong("permissions")); - if (!this.requirePermissions(ctx, new Permissions(resultSet.getLong("permissions")), Permission.MODIFY_API_KEY)) return; + Permissions projectPermissions = new Permissions(resultSet.getLong("permissions")); + requestedPermissions = requestedPermissions.restrict(projectPermissions.bits()); + if (!this.requirePermissions(ctx, projectPermissions, Permission.MODIFY_API_KEY)) return; } } - case "user" -> permissions = permissions.restrict( - Permission.DEFAULT_USER_PERMISSIONS.bits() | userPermissions.bits()); + case "user" -> requestedPermissions = requestedPermissions.restrict( + Permission.DEFAULT_USER_PERMISSIONS.bits() | scopePermissions.bits()); } try ( @@ -112,7 +113,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions userPermissi // actually what the hell lmao. what is this second integer? apiKeyScopeStatement.setNull(3, 0); } - apiKeyScopeStatement.setLong(4, permissions.bits()); + apiKeyScopeStatement.setLong(4, requestedPermissions.bits()); apiKeyScopeStatement.execute(); } 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 index d972c7d..3aab21d 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java @@ -33,7 +33,7 @@ public ListKeysEndpoint() { public void handle( @NotNull Context ctx, String userId, - Permissions userPermissions + Permissions scopePermissions ) throws Exception { String projectId = ctx.queryParam("project_id"); String query; From bb823cd0f826171d19960465499c001c9997a66a Mon Sep 17 00:00:00 2001 From: sylv256 Date: Wed, 8 Oct 2025 15:59:14 -0400 Subject: [PATCH 39/98] fix(auth): correctly restrict scope permissions to API key permissions --- .../net/modgarden/backend/endpoint/AuthorizedEndpoint.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index b181574..053ff11 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -204,7 +204,7 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { // give this endpoint the permissions as specified by the API key if (authorized) { - scopePermissions.grantPermissions(new Permissions(apiKeyScopeResult.getLong("permissions"))); + Permissions apiKeyPermissions = new Permissions(apiKeyScopeResult.getLong("permissions")); // Disallow permissions the user doesn't already have switch (scope) { case USER -> { @@ -216,7 +216,7 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { return ValidationResult.no(); } scopePermissions = userPermissions; - scopePermissions = scopePermissions.restrict(userPermissions.bits()); + scopePermissions = scopePermissions.restrict(apiKeyPermissions.bits()); } case PROJECT -> { Permissions projectPermissions = this.getDatabaseAccess() @@ -227,7 +227,7 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { return ValidationResult.no(); } scopePermissions = projectPermissions; - scopePermissions = scopePermissions.restrict(projectPermissions.bits()); + scopePermissions = scopePermissions.restrict(apiKeyPermissions.bits()); } } } From dd2408e58a99d106230a198b8bd5efb7512618d5 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Thu, 9 Oct 2025 00:14:31 -0400 Subject: [PATCH 40/98] perf: rewrite `generateFromNumber` from recursive to iterative --- .../net/modgarden/backend/data/NaturalId.java | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 67b5fd6..0295ddf 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -46,27 +46,18 @@ private static String generateUnchecked(int length) { @NotNull public static String generateFromNumber(int number, int length) { -// number += ALPHABET.length(); // hack, do not remove or tiny pineapple will steal your computer - String result = generateFromNumberRecursive(number); - if (result.length() > length) { - throw new IllegalArgumentException("Number " + number + " cannot be represented in this length " + length); - } else { - String padding = "a".repeat(length - result.length()); - return padding + result; - } - } + int base = ALPHABET.length(); + int iterations = (int) (Math.log(number * base) / Math.log(base)); // what the fuck - @NotNull - private static String generateFromNumberRecursive(int number) { - int iterations = number / ALPHABET.length(); - int remainder = number % ALPHABET.length(); + StringBuilder result = new StringBuilder(); - if (iterations == 0) { - return "" + ALPHABET.charAt(remainder); - } else { - String result = generateFromNumberRecursive(iterations); - return result + ALPHABET.charAt(remainder); + for (int i = 0; i < iterations; i++) { + result.append(ALPHABET.charAt(number % base)); + number = number / base; } + + String padding = ("" + ALPHABET.charAt(0)).repeat(length - result.length()); + return padding + result.reverse(); } @NotNull From cd751409339b5c26b2be77a49bfccbee12d7f920 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Fri, 10 Oct 2025 18:06:32 -0400 Subject: [PATCH 41/98] docs(auth): document security incident 2025-10-08 --- .../backend/endpoint/AuthorizedEndpoint.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 053ff11..684cb55 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -98,11 +98,18 @@ public final void handle(@NotNull Context ctx) throws Exception { /// /// ## Past Incidents /// Security incidents related to this method are detailed below. - /// If an incident is not documented, create a sub-heading with the date - /// and an ominous title. + /// 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` No Incidents! + /// ### `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) { From 8c07d935aa6665beb08e1b3bc06f95bb8ffbfd04 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 11 Oct 2025 15:55:31 -0400 Subject: [PATCH 42/98] feat: Manage CDN Permission --- src/main/java/net/modgarden/backend/data/Permission.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index 5f6e388..a71efbf 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -24,7 +24,9 @@ public enum Permission { /// 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),; + MODIFY_API_KEY(0x40, "modify_api_key", ALL), + /// List, modify, and delete files in the CDN. + MANAGE_CDN(0x80, "manage_cdn", USER),; /// The default permissions that all users have. /// From 1cc2afe386df00ed893e883f570088cb89504425 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Wed, 19 Nov 2025 17:44:55 +1100 Subject: [PATCH 43/98] feat: Update project and submission structure based on #12. --- .../net/modgarden/backend/data/Platform.java | 8 + .../backend/data/award/AwardInstance.java | 4 +- .../modgarden/backend/data/event/Project.java | 240 ++--------------- .../backend/data/event/Submission.java | 248 ++---------------- .../event/platform/DownloadUrlPlatform.java | 34 +++ .../data/event/platform/ModrinthPlatform.java | 39 +++ .../backend/util/ExtObjectCodec.java | 77 ++++++ .../modgarden/backend/util/ExtraCodecs.java | 3 + 8 files changed, 210 insertions(+), 443 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/data/Platform.java create mode 100644 src/main/java/net/modgarden/backend/data/event/platform/DownloadUrlPlatform.java create mode 100644 src/main/java/net/modgarden/backend/data/event/platform/ModrinthPlatform.java create mode 100644 src/main/java/net/modgarden/backend/util/ExtObjectCodec.java 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..68ccbde --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/Platform.java @@ -0,0 +1,8 @@ +package net.modgarden.backend.data; + +import com.mojang.serialization.MapCodec; + +public interface Platform { + String getName(); + MapCodec getCodec(); +} 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 2c5f784..9ac3807 100644 --- a/src/main/java/net/modgarden/backend/data/award/AwardInstance.java +++ b/src/main/java/net/modgarden/backend/data/award/AwardInstance.java @@ -8,13 +8,13 @@ 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/Project.java b/src/main/java/net/modgarden/backend/data/event/Project.java index 4243501..10a03fd 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -1,243 +1,57 @@ 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.codecs.RecordCodecBuilder; -import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.user.User; -import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.util.ExtraCodecs; 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; // 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, - List authors, - List builders) { + String slug, + ProjectMetadata metadata, + Map team, + Map permissions, + Map ext) { 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), - User.ID_CODEC.listOf().fieldOf("authors").forGetter(Project::authors), - User.ID_CODEC.listOf().fieldOf("builders").forGetter(Project::builders) + ProjectMetadata.CODEC.fieldOf("metadata").forGetter(Project::metadata), + Codec.unboundedMap(User.ID_CODEC, Codec.STRING).fieldOf("team").forGetter(Project::team), + Codec.unboundedMap(User.ID_CODEC, Codec.STRING).fieldOf("permissions").forGetter(Project::permissions), + ExtraCodecs.EXT_CODEC.fieldOf("ext").forGetter(Project::ext) ).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(Endpoint.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) { - try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectBySlug())) { - prepared.setString(1, slug); - 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"), - authors, - builders - ); - } catch (SQLException ex) { - ModGardenBackend.LOG.error("Exception in SQL query.", ex); - } - return null; - } - - 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"), - 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(Endpoint.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + user + "'."); - ctx.status(422); - return; - } + private static DataResult validate(String id) { try (Connection connection = ModGardenBackend.createDatabaseConnection(); - PreparedStatement prepared = connection.prepareStatement(selectAllByUser())) { - prepared.setString(1, user); - prepared.setString(2, user); + PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM projects WHERE id = ?")) { + prepared.setString(1, id); 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.add("authors", authors); - projectObject.add("builders", builders); - projectList.add(projectObject); - } - ctx.json(projectList); + 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 + "'."); } - private static String selectById() { - return """ - SELECT - p.id, - p.slug, - 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 - """; - } - - private static String selectBySlug() { - return """ - SELECT - p.id, - p.slug, - 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 - """; + public record ProjectMetadata(String modId, String name, String description, + String sourceUrl, String iconUrl, String bannerUrl) { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("mod_id").forGetter(ProjectMetadata::modId), + Codec.STRING.fieldOf("name").forGetter(ProjectMetadata::name), + Codec.STRING.fieldOf("description").forGetter(ProjectMetadata::description), + Codec.STRING.fieldOf("source_url").forGetter(ProjectMetadata::sourceUrl), + Codec.STRING.fieldOf("icon_url").forGetter(ProjectMetadata::iconUrl), + Codec.STRING.fieldOf("banner_url").forGetter(ProjectMetadata::bannerUrl) + ).apply(inst, ProjectMetadata::new)); } - - private static String selectAllByUser() { - return """ - SELECT p.id, - p.slug, - 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 - """; - } - - private static String selectAllByEvent() { - return """ - SELECT p.id, - p.slug, - 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 - """; - } - - 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 e9c6939..489ec03 100644 --- a/src/main/java/net/modgarden/backend/data/event/Submission.java +++ b/src/main/java/net/modgarden/backend/data/event/Submission.java @@ -1,254 +1,46 @@ 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 io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.data.Platform; +import net.modgarden.backend.data.event.platform.DownloadUrlPlatform; +import net.modgarden.backend.data.event.platform.ModrinthPlatform; 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.time.ZoneId; import java.time.ZonedDateTime; +import java.util.Map; + +import static java.util.Map.entry; -// 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, + ZonedDateTime submitted, Project project, - String modrinthVersionId, - ZonedDateTime submitted) { + Platform platform) { + private static final Map> PLATFORM_CODECS = Map.ofEntries( + entry("modrinth", mapPlatformCodec(ModrinthPlatform.CODEC)), + entry("download_url", mapPlatformCodec(DownloadUrlPlatform.CODEC)) + ); + @SuppressWarnings("unchecked") + private static

MapCodec mapPlatformCodec(MapCodec

platformCodec) { + return platformCodec.xmap(p -> p, p -> (P)p); + } + 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), + ExtraCodecs.ISO_DATE_TIME.fieldOf("time_submitted").forGetter(Submission::submitted), 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) + Codec.STRING.dispatch(Platform::getName, PLATFORM_CODECS::get).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(Endpoint.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(Endpoint.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(Endpoint.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(Endpoint.SAFE_URL_REGEX)) { - ctx.result("Illegal characters in path '" + user + "'."); - ctx.status(422); - return; - } - if (!event.matches(Endpoint.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/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..7702925 --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/event/platform/ModrinthPlatform.java @@ -0,0 +1,39 @@ +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 +/// { +/// "project_id": "LQCrGzOR", +/// "version_id": "Qt7I0urr" +/// "slug": "variant-lib" +/// } +/// ``` +/// +/// @param projectId The project ID of the Modrinth project. +/// @param versionId The version ID to pull from Modrinth for the mod JAR. +/// @param slug The slug of the Modrinth project. +/// +public record ModrinthPlatform(String projectId, String versionId, String slug) 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), + Codec.STRING.fieldOf("slug").forGetter(ModrinthPlatform::slug) + ).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/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 b370023..2989c38 100644 --- a/src/main/java/net/modgarden/backend/util/ExtraCodecs.java +++ b/src/main/java/net/modgarden/backend/util/ExtraCodecs.java @@ -6,6 +6,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Map; import java.util.UUID; public class ExtraCodecs { @@ -21,4 +22,6 @@ public class ExtraCodecs { string -> Instant.ofEpochMilli(Long.parseLong(string)), instant -> Long.toString(instant.toEpochMilli()) ); + + public static final Codec> EXT_CODEC = Codec.unboundedMap(Codec.STRING, new ExtObjectCodec()); } From 9bd46aad8260e9e044c51402714d407f1311e480 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 03:18:24 +1100 Subject: [PATCH 44/98] feat: Update Event class structure. --- .../modgarden/backend/data/event/Event.java | 327 +----------------- 1 file changed, 9 insertions(+), 318 deletions(-) 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 bbf610c..b6e88e9 100644 --- a/src/main/java/net/modgarden/backend/data/event/Event.java +++ b/src/main/java/net/modgarden/backend/data/event/Event.java @@ -24,308 +24,33 @@ public record Event(String id, String slug, + String eventTypeSlug, String displayName, Optional discordRoleId, String minecraftVersion, String loader, - ZonedDateTime registrationTime, + ZonedDateTime registrationOpenTime, + ZonedDateTime registrationCloseTime, ZonedDateTime startTime, ZonedDateTime endTime, ZonedDateTime freezeTime) { - // TODO: Endpoint for creating events. 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("registration_open_time").forGetter(Event::registrationOpenTime), + ExtraCodecs.ISO_DATE_TIME.fieldOf("registration_close_time").forGetter(Event::registrationCloseTime), 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) ).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(Endpoint.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); @@ -337,38 +62,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;"; - } } From dd8bda9d5e3196616537a1af8fcabb9aac62ddff Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 03:32:02 +1100 Subject: [PATCH 45/98] fix: Idea ext run configurations. --- build.gradle.kts | 40 +++++++++++++++++++++------------------ gradle/libs.versions.toml | 4 ++-- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d6709fe..6085ce5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,12 @@ +import org.jetbrains.gradle.ext.runConfigurations +import org.jetbrains.gradle.ext.settings + plugins { application java idea `java-library-distribution` - // doesn't work :( -// alias(libs.plugins.idea.ext) apply true + alias(libs.plugins.idea.ext) apply true } group = "net.modgarden" @@ -80,19 +82,21 @@ application { mainClass = "net.modgarden.backend.ModGardenBackend" } -// fixme wake me up when september ends (when idea_ext is fixed) -//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" -// ) -// } -// } -// } -//} +idea { + 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/libs.versions.toml b/gradle/libs.versions.toml index 64426c3..1f63cd0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ jetbrains_annotations = "0.1.3" argon2-jvm = "2.12" -#idea_ext = "1.1.9" +idea_ext = "1.3" [libraries] dfu = { group = "com.mojang", name = "datafixerupper", version.ref = "dfu" } @@ -29,4 +29,4 @@ jetbrains_annotations = { group = "org.jetbrains", name = "annotations", version 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" } +idea_ext = { id = "org.jetbrains.gradle.plugin.idea-ext", version.ref = "idea_ext" } From fbe2685040f91e0c1a78b85f5e3e721a560a4c11 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 03:38:36 +1100 Subject: [PATCH 46/98] docs(build): Add notes for build script visual errors. --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 6085ce5..50d0640 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,6 +82,8 @@ application { mainClass = "net.modgarden.backend.ModGardenBackend" } +// When refreshing the project, the entire build.gradle.kts may error because of IDEA Ext. +// Refresh the project again to fix this. We'll likely have to report this to JetBrains. idea { project { settings { From eca3ffe91011ea647676955b70aa9a60b3a67c0b Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 03:43:57 +1100 Subject: [PATCH 47/98] chore: Update dependencies and remove unused dependencies. --- build.gradle.kts | 2 -- gradle/libs.versions.toml | 10 +++------- .../java/net/modgarden/backend/data/award/Award.java | 2 -- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 50d0640..b5b000e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,12 +29,10 @@ 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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f63cd0..31239c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,10 @@ [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" argon2-jvm = "2.12" @@ -19,11 +17,9 @@ 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" } 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 2f87a86..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,7 +5,6 @@ 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; @@ -21,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), From 90e65368be20e2c6d7112dbe8fc3b2a461285dc6 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 03:46:09 +1100 Subject: [PATCH 48/98] docs(build): Update error comment to specify that it's only visual. --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index b5b000e..40accaa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,7 +80,7 @@ application { mainClass = "net.modgarden.backend.ModGardenBackend" } -// When refreshing the project, the entire build.gradle.kts may error because of IDEA Ext. +// 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 { From 5fe2054f7ac262fbd7e01ceaf7c4143661da2ae2 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 04:15:11 +1100 Subject: [PATCH 49/98] refactor: Set Event time related fields as longs instead of ISO time. --- .../modgarden/backend/data/event/Event.java | 31 ++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) 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 b6e88e9..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,25 +1,14 @@ 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 io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; -import net.modgarden.backend.endpoint.Endpoint; -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, @@ -29,11 +18,11 @@ public record Event(String id, Optional discordRoleId, String minecraftVersion, String loader, - ZonedDateTime registrationOpenTime, - ZonedDateTime registrationCloseTime, - ZonedDateTime startTime, - ZonedDateTime endTime, - ZonedDateTime freezeTime) { + 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), @@ -42,11 +31,11 @@ public record Event(String id, 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_open_time").forGetter(Event::registrationOpenTime), - ExtraCodecs.ISO_DATE_TIME.fieldOf("registration_close_time").forGetter(Event::registrationCloseTime), - 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::validate); From 8762b3bad70a006ca61ab3126eb8a764fb5dba51 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 04:21:33 +1100 Subject: [PATCH 50/98] refactor: Make Submission time submitted field a long instead of ISO time. --- .../java/net/modgarden/backend/data/event/Submission.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 489ec03..d944e9b 100644 --- a/src/main/java/net/modgarden/backend/data/event/Submission.java +++ b/src/main/java/net/modgarden/backend/data/event/Submission.java @@ -8,20 +8,18 @@ import net.modgarden.backend.data.Platform; import net.modgarden.backend.data.event.platform.DownloadUrlPlatform; import net.modgarden.backend.data.event.platform.ModrinthPlatform; -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.ZonedDateTime; import java.util.Map; import static java.util.Map.entry; public record Submission(String id, String event, - ZonedDateTime submitted, + long timeSubmitted, Project project, Platform platform) { private static final Map> PLATFORM_CODECS = Map.ofEntries( @@ -36,7 +34,7 @@ private static

MapCodec mapPlatformCodec(MapCodec 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), - ExtraCodecs.ISO_DATE_TIME.fieldOf("time_submitted").forGetter(Submission::submitted), + Codec.LONG.fieldOf("time_submitted").forGetter(Submission::timeSubmitted), Project.DIRECT_CODEC.fieldOf("project").forGetter(Submission::project), Codec.STRING.dispatch(Platform::getName, PLATFORM_CODECS::get).fieldOf("platform").forGetter(Submission::platform) ).apply(inst, Submission::new)); From 118cef0cf5c8498d8640107533e0e74a8e418013 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 04:22:18 +1100 Subject: [PATCH 51/98] refactor: Represent permissions values as a long. --- src/main/java/net/modgarden/backend/data/event/Project.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 10a03fd..a07b617 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -18,14 +18,14 @@ public record Project(String id, String slug, ProjectMetadata metadata, Map team, - Map permissions, + Map permissions, Map ext) { 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), ProjectMetadata.CODEC.fieldOf("metadata").forGetter(Project::metadata), Codec.unboundedMap(User.ID_CODEC, Codec.STRING).fieldOf("team").forGetter(Project::team), - Codec.unboundedMap(User.ID_CODEC, Codec.STRING).fieldOf("permissions").forGetter(Project::permissions), + Codec.unboundedMap(User.ID_CODEC, Codec.LONG).fieldOf("permissions").forGetter(Project::permissions), ExtraCodecs.EXT_CODEC.fieldOf("ext").forGetter(Project::ext) ).apply(inst, Project::new))); public static final Codec ID_CODEC = Codec.STRING.validate(Project::validate); From 6005be4244768771b2216bc3e846b4aec310f536 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 05:43:42 +1100 Subject: [PATCH 52/98] fix: Account for a DFU ordering bug. --- .../modgarden/backend/ModGardenBackend.java | 27 ++++--- .../util/OrderCorrectedRecordCodec.java | 80 +++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index f6c434c..f3de9de 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -26,6 +26,7 @@ import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; import net.modgarden.backend.endpoint.v2.auth.ListKeysEndpoint; import net.modgarden.backend.util.AuthUtil; +import net.modgarden.backend.util.OrderCorrectedRecordCodec; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -85,17 +86,17 @@ public static void main(String[] args) { 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(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(GenerateKeyEndpoint.Request.class, GenerateKeyEndpoint.Request.CODEC); - CODEC_REGISTRY.put(GenerateKeyEndpoint.Response.class, GenerateKeyEndpoint.Response.CODEC); - CODEC_REGISTRY.put(ListKeysEndpoint.Response.class, ListKeysEndpoint.Response.CODEC); + 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.Request.class, GenerateKeyEndpoint.Request.CODEC); + registerCodec(GenerateKeyEndpoint.Response.class, GenerateKeyEndpoint.Response.CODEC); + registerCodec(ListKeysEndpoint.Response.class, ListKeysEndpoint.Response.CODEC); Landing.createInstance(); AuthUtil.clearTokensEachFifteenMinutes(); @@ -410,6 +411,10 @@ private static void updateSchemaVersion() { LOG.debug("Updated database schema version."); } + private static void registerCodec(Type type, Codec codec) { + CODEC_REGISTRY.put(type, new OrderCorrectedRecordCodec<>(codec)); + } + private static JsonMapper createDFUMapper() { return new JsonMapper() { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); diff --git a/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java b/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java new file mode 100644 index 0000000..d35cd01 --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java @@ -0,0 +1,80 @@ +package net.modgarden.backend.util; + +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.*; + +import java.util.List; + +/** + * Accounts for a DFU bug where RecordCodecBuilder swaps the half-point at which members are encoded. + *

+ * This should only ever modify map encoding, which is where this bug is present. + * + * @see Mojang/DataFixerUpper#101 + * @param The type parameter of the RecordCodecBuilder. + */ +@SuppressWarnings("ClassCanBeRecord") +public class OrderCorrectedRecordCodec implements Codec { + private final Codec codec; + + public OrderCorrectedRecordCodec(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 -> { + DataResult> mapLike = ops.getMap(value); + if (!mapLike.hasResultOrPartial()) { + return value; + } + return correctEncoding( + ops, + ops.mapBuilder(), + mapLike.getOrThrow() + ).build(ops.empty()) + .resultOrPartial() + .orElse(value); + }); + } + + private static RecordBuilder correctEncoding(DynamicOps ops, RecordBuilder builder, MapLike newValues) { + if (newValues.entries().count() > 4) { + List> elements = newValues.entries().toList(); + + for (int secondHalfIndex = (int)Math.ceil(elements.size() / 2.0F); secondHalfIndex < elements.size(); ++secondHalfIndex) { + T key = elements.get(secondHalfIndex).getFirst(); + T value = potentiallyCorrectElement(ops, elements.get(secondHalfIndex).getSecond()); + builder.add(key, value); + } + for (int firstHalfIndex = 0; firstHalfIndex < Math.ceil(elements.size() / 2.0F); ++firstHalfIndex) { + T key = elements.get(firstHalfIndex).getFirst(); + T value = potentiallyCorrectElement(ops, elements.get(firstHalfIndex).getSecond()); + builder.add(key, value); + } + } else { + for (Pair entry : newValues.entries().toList()) { + T key = entry.getFirst(); + T value = potentiallyCorrectElement(ops, entry.getFirst()); + builder.add(key, value); + } + } + + return builder; + } + + private static T potentiallyCorrectElement(DynamicOps ops, T element) { + var mapResult = ops.getMap(element).resultOrPartial(); + if (mapResult.isPresent()) { + return correctEncoding(ops, ops.mapBuilder(), mapResult.get()).build(ops.empty()) + .resultOrPartial() + .orElse(element); + } + return element; + } +} From bf8849e9fe9c022e28d9412558db24420f6f9965 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 08:57:10 +1100 Subject: [PATCH 53/98] fix: Values for maps with less than 4 entries no longer resolve incorrectly. --- .../net/modgarden/backend/util/OrderCorrectedRecordCodec.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java b/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java index d35cd01..e0727eb 100644 --- a/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java +++ b/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java @@ -60,7 +60,7 @@ private static RecordBuilder correctEncoding(DynamicOps ops, RecordBui } else { for (Pair entry : newValues.entries().toList()) { T key = entry.getFirst(); - T value = potentiallyCorrectElement(ops, entry.getFirst()); + T value = potentiallyCorrectElement(ops, entry.getSecond()); builder.add(key, value); } } From fdde12cac69766077b4fa06f0a61e2126b4557cb Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 09:37:29 +1100 Subject: [PATCH 54/98] feat: Refactors relating to projects and submissions, implement endpoints for getting projects. --- .../modgarden/backend/ModGardenBackend.java | 92 +++++----- .../modgarden/backend/data/event/Project.java | 75 ++++++-- .../backend/data/fixer/fix/V5ToV6.java | 86 ++++++---- .../backend/database/DatabaseFunction.java | 16 ++ .../GenerateNaturalIdFromNumberFunction.java | 24 +++ .../function/GenerateNaturalIdFunction.java | 26 +++ .../database/function/UnixMillisFunction.java | 22 +++ .../v2/project/GetProjectByIdEndpoint.java | 47 +++++ .../v2/project/GetProjectByModIdEndpoint.java | 48 ++++++ .../v2/project/GetProjectEndpoint.java | 68 ++++++++ .../backend/util/metadata/MetadataUtils.java | 161 ++++++++++++++++++ .../util/metadata/ModrinthMetadataUtils.java | 62 +++++++ 12 files changed, 629 insertions(+), 98 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/database/DatabaseFunction.java create mode 100644 src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFromNumberFunction.java create mode 100644 src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java create mode 100644 src/main/java/net/modgarden/backend/database/function/UnixMillisFunction.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/util/metadata/MetadataUtils.java create mode 100644 src/main/java/net/modgarden/backend/util/metadata/ModrinthMetadataUtils.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index f3de9de..e6552b9 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -21,16 +21,20 @@ import net.modgarden.backend.data.event.Submission; import net.modgarden.backend.data.fixer.DatabaseFixer; import net.modgarden.backend.data.user.User; +import net.modgarden.backend.database.function.GenerateNaturalIdFromNumberFunction; +import net.modgarden.backend.database.function.GenerateNaturalIdFunction; +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.GetProjectByIdEndpoint; +import net.modgarden.backend.endpoint.v2.project.GetProjectByModIdEndpoint; import net.modgarden.backend.util.AuthUtil; import net.modgarden.backend.util.OrderCorrectedRecordCodec; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.sqlite.Function; import java.io.File; import java.io.IOException; @@ -39,7 +43,6 @@ import java.lang.reflect.Type; import java.net.http.HttpClient; import java.sql.*; -import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.Properties; @@ -70,22 +73,6 @@ public static void main(String[] args) { ModGardenBackend.LOG.debug("1 {}, 4 {}, 26 {}, 29 {}, 52 {}, 53 {}, 79 {}", NaturalId.generateFromNumber(1, 2), NaturalId.generateFromNumber(4, 2), NaturalId.generateFromNumber(26, 2), NaturalId.generateFromNumber(29, 2), NaturalId.generateFromNumber(52, 2), NaturalId.generateFromNumber(53, 2), NaturalId.generateFromNumber(79, 2)); ModGardenBackend.LOG.debug("1 {}, 4 {}, 26 {}, 29 {}, 52 {}, 53 {}, 79 {}, 675 {}, 676 {}, 677 {}", NaturalId.generateFromNumber(1, 3), NaturalId.generateFromNumber(4, 3), NaturalId.generateFromNumber(26, 3), NaturalId.generateFromNumber(29, 3), NaturalId.generateFromNumber(52, 3), NaturalId.generateFromNumber(53, 3), NaturalId.generateFromNumber(79, 3), NaturalId.generateFromNumber(675, 3), NaturalId.generateFromNumber(676, 3), NaturalId.generateFromNumber(677, 3)); - try { - boolean createdFile = new File("./database.db").createNewFile(); - DatabaseFixer.createFixers(); - if (createdFile) { - createDatabaseContents(); - updateSchemaVersion(); - LOG.debug("Successfully created database file."); - } - DatabaseFixer.fixDatabase(); - if (!createdFile) { - updateSchemaVersion(); - } - } catch (IOException ex) { - LOG.error("Failed to create database file.", ex); - } - registerCodec(Landing.class, Landing.CODEC); registerCodec(BackendError.class, BackendError.CODEC); registerCodec(Award.class, Award.DIRECT_CODEC); @@ -116,12 +103,32 @@ public static void main(String[] args) { app.start(7070); LOG.info("Mod Garden Backend Started!"); + + + try { + boolean createdFile = new File("./database.db").createNewFile(); + DatabaseFixer.createFixers(); + if (createdFile) { + createDatabaseContents(); + updateSchemaVersion(); + LOG.debug("Successfully created database file."); + } + DatabaseFixer.fixDatabase(); + if (!createdFile) { + updateSchemaVersion(); + } + } catch (IOException ex) { + LOG.error("Failed to create database file.", ex); + } } public void v2() { post(GenerateKeyEndpoint::new); delete(DeleteKeyEndpoint::new); get(ListKeysEndpoint::new); + + get(GetProjectByIdEndpoint::new); + get(GetProjectByModIdEndpoint::new); } private void get(Supplier endpointSupplier) { @@ -267,10 +274,22 @@ PRIMARY KEY (id) statement.addBatch(""" CREATE TABLE IF NOT EXISTS projects ( id TEXT UNIQUE NOT NULL, - slug TEXT UNIQUE NOT NULL, PRIMARY KEY (id) ) """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_metadata ( + project_id TEXT UNIQUE NOT NULL, + mod_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + source_url TEXT NOT NULL, + icon_url TEXT NOT NULL, + banner_url TEXT, + 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, @@ -352,36 +371,11 @@ FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (code) ) """); - Function.create( - connection, "generate_natural_id", new Function() { - @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)); - } - } - ); - Function.create( - connection, "generate_natural_id_from_number", new Function() { - @Override - protected void xFunc() throws SQLException { - int number = this.value_int(0); - int length = this.value_int(1); - this.result(NaturalId.generateFromNumber(number, length)); - } - } - ); - Function.create( - connection, "unix_millis", new Function() { - @Override - protected void xFunc() throws SQLException { - this.result(Instant.now().toEpochMilli()); - } - } - ); + + GenerateNaturalIdFunction.INSTANCE.create(connection); + GenerateNaturalIdFromNumberFunction.INSTANCE.create(connection); + UnixMillisFunction.INSTANCE.create(connection); + statement.executeBatch(); } catch (SQLException ex) { LOG.error("Failed to create database tables. ", ex); 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 a07b617..adcaa0b 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -6,26 +6,31 @@ import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.user.User; 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.util.List; import java.util.Map; +import java.util.Optional; // TODO: Allow creating organisations, allow projects to be attributed to an organisation. public record Project(String id, - String slug, - ProjectMetadata metadata, + Type type, + Metadata metadata, Map team, Map permissions, + List submissions, Map ext) { 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), - ProjectMetadata.CODEC.fieldOf("metadata").forGetter(Project::metadata), + Type.CODEC.fieldOf("type").forGetter(Project::type), + 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), ExtraCodecs.EXT_CODEC.fieldOf("ext").forGetter(Project::ext) ).apply(inst, Project::new))); public static final Codec ID_CODEC = Codec.STRING.validate(Project::validate); @@ -43,15 +48,57 @@ private static DataResult validate(String id) { return DataResult.error(() -> "Failed to get project with id '" + id + "'."); } - public record ProjectMetadata(String modId, String name, String description, - String sourceUrl, String iconUrl, String bannerUrl) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("mod_id").forGetter(ProjectMetadata::modId), - Codec.STRING.fieldOf("name").forGetter(ProjectMetadata::name), - Codec.STRING.fieldOf("description").forGetter(ProjectMetadata::description), - Codec.STRING.fieldOf("source_url").forGetter(ProjectMetadata::sourceUrl), - Codec.STRING.fieldOf("icon_url").forGetter(ProjectMetadata::iconUrl), - Codec.STRING.fieldOf("banner_url").forGetter(ProjectMetadata::bannerUrl) - ).apply(inst, ProjectMetadata::new)); + public record Metadata(String modId, String name, @Nullable String description, + String sourceUrl, String iconUrl, @Nullable String bannerUrl) { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("mod_id").forGetter(Metadata::modId), + Codec.STRING.fieldOf("name").forGetter(Metadata::name), + Codec.STRING.optionalFieldOf("description").forGetter(Metadata::descriptionAsOptional), + Codec.STRING.fieldOf("source_url").forGetter(Metadata::sourceUrl), + Codec.STRING.fieldOf("icon_url").forGetter(Metadata::iconUrl), + Codec.STRING.optionalFieldOf("banner_url").forGetter(Metadata::bannerUrlAsOptional) + ).apply(inst, Metadata::new)); + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private Metadata(String modId, String name, Optional description, String sourceUrl, String iconUrl, Optional bannerUrl) { + this(modId, name, description.orElse(null), sourceUrl, iconUrl, bannerUrl.orElse(null)); + } + + private Optional descriptionAsOptional() { + return Optional.ofNullable(description); + } + + private Optional bannerUrlAsOptional() { + return Optional.ofNullable(bannerUrl); + } + } + + public enum Type { + MOD("mod"),; + + public static final Codec CODEC = Codec.STRING.comapFlatMap(string -> { + Type type = fromString(string); + return type == null ? DataResult.error(() -> "Could not find project type '" + string + "'.") : + DataResult.success(type); + }, Type::getName); + + private final String name; + + Type(String name) { + this.name = name; + } + + public static Type fromString(String value) { + for (Type type : Type.values()) { + if (type.getName().equals(value)) { + return type; + } + } + return null; + } + + public String getName() { + return name; + } } } 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 index 60532e3..da29368 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,13 +1,15 @@ package net.modgarden.backend.data.fixer.fix; -import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.fixer.DatabaseFix; +import net.modgarden.backend.database.function.GenerateNaturalIdFromNumberFunction; +import net.modgarden.backend.database.function.GenerateNaturalIdFunction; +import net.modgarden.backend.database.function.UnixMillisFunction; +import net.modgarden.backend.util.metadata.MetadataUtils; import org.jetbrains.annotations.Nullable; import org.sqlite.Function; import java.sql.Connection; import java.sql.SQLException; -import java.time.Instant; import java.util.function.Consumer; public class V5ToV6 extends DatabaseFix { @@ -19,36 +21,9 @@ public V5ToV6() { public @Nullable Consumer fix(Connection connection) throws SQLException { var statement = connection.createStatement(); - Function.create( - connection, "generate_natural_id", new Function() { - @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)); - } - } - ); - Function.create( - connection, "generate_natural_id_from_number", new Function() { - @Override - protected void xFunc() throws SQLException { - int number = this.value_int(0); - int length = this.value_int(1); - this.result(NaturalId.generateFromNumber(number, length)); - } - } - ); - Function.create( - connection, "unix_millis", new Function() { - @Override - protected void xFunc() throws SQLException { - this.result(Instant.now().toEpochMilli()); - } - } - ); + GenerateNaturalIdFunction.INSTANCE.create(connection); + GenerateNaturalIdFromNumberFunction.INSTANCE.create(connection); + UnixMillisFunction.INSTANCE.create(connection); // temp functions for the datafixer Function.create( @@ -148,13 +123,12 @@ INSERT INTO submission_type_modrinth (submission_id, modrinth_id, version_id) statement.addBatch(""" CREATE TABLE IF NOT EXISTS projects ( id TEXT UNIQUE NOT NULL, - slug TEXT UNIQUE NOT NULL, PRIMARY KEY (id) ) """); statement.addBatch(""" - INSERT INTO projects (id, slug) - SELECT id, slug FROM projects_old + INSERT INTO projects (id) + SELECT id FROM projects_old """); statement.addBatch(""" @@ -437,9 +411,51 @@ WITH cnt(i) AS ( SET slug = clean_slug_mg(slug) """); + statement.addBatch(""" + CREATE TABLE IF NOT EXISTS project_metadata ( + project_id TEXT UNIQUE NOT NULL, + mod_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + source_url TEXT NOT NULL, + icon_url TEXT NOT NULL, + banner_url TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (project_id) + ) + """); statement.executeBatch(); + var modrinthSubmissions = 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 + """).executeQuery(); + var projectMetadataInsertStatement = connection.prepareStatement("INSERT INTO project_metadata VALUES (?, ?, ?, ?, ?, ?, ?)"); + + if (modrinthSubmissions.isBeforeFirst()) { + while (modrinthSubmissions.next()) { + String projectId = modrinthSubmissions.getString("project_id"); + String modrinthId = modrinthSubmissions.getString("modrinth_id"); + String modrinthVersionId = modrinthSubmissions.getString("version_id"); + + try { + var modrinthData = MetadataUtils.getMetadataFromModrinth(modrinthId, modrinthVersionId); + projectMetadataInsertStatement.setString(1, projectId); + projectMetadataInsertStatement.setString(2, modrinthData.modId()); + projectMetadataInsertStatement.setString(3, modrinthData.name()); + projectMetadataInsertStatement.setString(4, modrinthData.description()); + projectMetadataInsertStatement.setString(5, modrinthData.sourceUrl()); + projectMetadataInsertStatement.setString(6, modrinthData.iconUrl()); + projectMetadataInsertStatement.setString(7, modrinthData.bannerUrl()); + projectMetadataInsertStatement.executeUpdate(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + return dropConnection -> { try { var dropStatement = dropConnection.createStatement(); 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/GenerateNaturalIdFromNumberFunction.java b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFromNumberFunction.java new file mode 100644 index 0000000..d8f58c5 --- /dev/null +++ b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFromNumberFunction.java @@ -0,0 +1,24 @@ +package net.modgarden.backend.database.function; + +import net.modgarden.backend.data.NaturalId; +import net.modgarden.backend.database.DatabaseFunction; + +import java.sql.SQLException; + +public class GenerateNaturalIdFromNumberFunction extends DatabaseFunction { + public static final GenerateNaturalIdFromNumberFunction INSTANCE = new GenerateNaturalIdFromNumberFunction(); + + protected GenerateNaturalIdFromNumberFunction() {} + + @Override + protected void xFunc() throws SQLException { + int number = this.value_int(0); + int length = this.value_int(1); + this.result(NaturalId.generateFromNumber(number, length)); + } + + @Override + protected String getName() { + return "generate_natural_id_from_number"; + } +} 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/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/v2/project/GetProjectByIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java new file mode 100644 index 0000000..e51bd7e --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java @@ -0,0 +1,47 @@ +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/id/{project_id}") +public class GetProjectByIdEndpoint extends GetProjectEndpoint { + public GetProjectByIdEndpoint() { + super("id/{project_id}"); + } + + @Override + public void handle(@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 = getProjectFromId(connection, projectId); + + if (project == null) { + ctx.result("Could not create project object from id '" + projectId + "'."); + ctx.status(500); + return; + } + + 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..7a04cb5 --- /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 handle(@NotNull Context ctx) throws Exception { + String modId = ctx.pathParam("mod_id"); + + try ( + var connection = this.getDatabaseConnection(); + var projectStatement = connection.prepareStatement("SELECT project_id FROM project_metadata WHERE mod_id = ?") + ) { + projectStatement.setString(1, modId); + ResultSet projectResult = projectStatement.executeQuery(); + if (!projectResult.isBeforeFirst()) { + ctx.result("Could not find project from mod id '" + modId + "'."); + ctx.status(404); + return; + } + String projectId = projectResult.getString("project_id"); + Project project = getProjectFromId(connection, projectId); + + if (project == null) { + ctx.result("Could not create project object from mod id '" + modId + "'."); + ctx.status(500); + return; + } + + 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..12f0e0e --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -0,0 +1,68 @@ +package net.modgarden.backend.endpoint.v2.project; + +import io.javalin.http.Context; +import net.modgarden.backend.data.event.Project; +import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.endpoint.EndpointPath; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.util.*; + +@EndpointPath("/v2/auth") +public abstract class GetProjectEndpoint extends Endpoint { + public GetProjectEndpoint(String path) { + super(2, "project/" + path); + } + + @Override + public abstract void handle(@NotNull Context ctx) throws Exception; + + protected Project getProjectFromId(@NotNull Connection connection, @NotNull String projectId) throws Exception { + 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 projectMetadataStatement = connection.prepareStatement("SELECT mod_id, name, description, source_url, icon_url, banner_url FROM project_metadata WHERE project_id = ?"); + var submissionsStatement = connection.prepareStatement("SELECT id FROM submissions WHERE project_id = ?") + ) { + projectMetadataStatement.setString(1, projectId); + ResultSet projectMetadataResult = projectMetadataStatement.executeQuery(); + + 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, + // TODO: Add project types to database. + Project.Type.MOD, + new Project.Metadata( + projectMetadataResult.getString("mod_id"), + projectMetadataResult.getString("name"), + projectMetadataResult.getString("description"), + projectMetadataResult.getString("source_url"), + projectMetadataResult.getString("icon_url"), + projectMetadataResult.getString("banner_url") + ), + team, + permissions, + submissions, + // TODO: Add ext field to the database. + Collections.emptyMap() + ); + } + } +} diff --git a/src/main/java/net/modgarden/backend/util/metadata/MetadataUtils.java b/src/main/java/net/modgarden/backend/util/metadata/MetadataUtils.java new file mode 100644 index 0000000..3d8bbbf --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/metadata/MetadataUtils.java @@ -0,0 +1,161 @@ +package net.modgarden.backend.util.metadata; + +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.event.Project; +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.ArrayList; +import java.util.List; +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. +public class MetadataUtils { + private static final String USER_AGENT = "ModGardenEvent/backend/" + Landing.getInstance().version() + " (modgarden.net)"; + + public static Project.Metadata getMetadataFromModrinth(String modrinthProjectId, + String modrinthVersionId) throws Exception { + ModrinthOAuthClient authClient = OAuthService.MODRINTH.authenticate(); + + ExternalData externalData = ModrinthMetadataUtils.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 jarUri = null; + for (JsonElement potentialFile : version.getAsJsonArray("files")) { + if (potentialFile.isJsonObject()) { + JsonObject file = potentialFile.getAsJsonObject(); + if (file.getAsJsonPrimitive("primary").getAsBoolean()) { + jarUri = URI.create(file.getAsJsonPrimitive("url").getAsString()); + break; + } + } + } + + if (jarUri == null) { + throw new IllegalStateException("Could not find valid primary version URL from Modrinth version whilst getting project metadata."); + } + + List loaders = new ArrayList<>(); + for (JsonElement element : version.getAsJsonArray("loaders")) { + loaders.add(element.getAsJsonPrimitive().getAsString()); + } + + if (loaders.contains("fabric")) { + return getMetadataFromFabricModJson(jarUri, externalData); + } + throw new UnsupportedOperationException("All modloaders associated with the specified version are not implemented."); + } + } + + public static Project.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(); + + Project.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); + // TODO: Handle Icon and Banner Uploads to CDN. + String iconUrl = "placeholder"; + String bannerUrl = "placeholder"; + + metadata = new Project.Metadata( + modId, + name, + description, + sourceUrl, + iconUrl, + bannerUrl + ); + } + + 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 RuntimeException("Could not find source URL from either fabric.mod.json or external data."); + } + + public record ExternalData(@Nullable String externalSourceUrl, + @Nullable String externalIconUrl, + @Nullable String externalBannerUrl) { + + } +} diff --git a/src/main/java/net/modgarden/backend/util/metadata/ModrinthMetadataUtils.java b/src/main/java/net/modgarden/backend/util/metadata/ModrinthMetadataUtils.java new file mode 100644 index 0000000..796fda6 --- /dev/null +++ b/src/main/java/net/modgarden/backend/util/metadata/ModrinthMetadataUtils.java @@ -0,0 +1,62 @@ +package net.modgarden.backend.util.metadata; + +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 ModrinthMetadataUtils { + protected static MetadataUtils.ExternalData getModrinthExternalData(@NotNull ModrinthOAuthClient oAuthClient, + @NotNull String modrinthProjectId) throws Exception { + HttpResponse projectResponse = oAuthClient + .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); + String iconUrl = project.getAsJsonPrimitive("icon_url").getAsString(); + String bannerUrl = getBannerUrlFromModrinthProject(project); + + return new MetadataUtils.ExternalData(sourceUrl, iconUrl, bannerUrl); + } + } + + protected 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; + } + + protected static String getBannerUrlFromModrinthProject(JsonObject project) { + if (project.has("gallery")) { + for (JsonElement galleryElement : project.getAsJsonArray("gallery")) { + JsonObject galleryObject = galleryElement.getAsJsonObject(); + if (galleryObject.getAsJsonPrimitive("featured").getAsBoolean()) { + return galleryObject.getAsJsonPrimitive("url").getAsString(); + } + } + } + return null; + } +} From 6067caaf1726dcc68f421804d291d3fe50277cdc Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 09:43:55 +1100 Subject: [PATCH 55/98] docs: Refactor Javadoc for OrderCorrectedRecordCodec to use markdown. --- .../backend/util/OrderCorrectedRecordCodec.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java b/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java index e0727eb..a9e38e6 100644 --- a/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java +++ b/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java @@ -5,14 +5,13 @@ import java.util.List; -/** - * Accounts for a DFU bug where RecordCodecBuilder swaps the half-point at which members are encoded. - *

- * This should only ever modify map encoding, which is where this bug is present. - * - * @see Mojang/DataFixerUpper#101 - * @param The type parameter of the RecordCodecBuilder. - */ +/// Accounts for a DFU bug where RecordCodecBuilder swaps the half-point at which members are encoded. +/// +/// This should only ever modify map encoding, which is where this bug is present. +/// +/// @see Mojang/DataFixerUpper#101 +/// @param The type parameter of the RecordCodecBuilder. + @SuppressWarnings("ClassCanBeRecord") public class OrderCorrectedRecordCodec implements Codec { private final Codec codec; From 70b89d9be2745feed99925b4f8266305bd616454 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 15:51:28 +1100 Subject: [PATCH 56/98] feat: Add submission getting endpoints. --- .../modgarden/backend/ModGardenBackend.java | 5 ++ .../v2/event/GetSubmissionByIdEndpoint.java | 66 +++++++++++++++ .../event/GetSubmissionByModIdEndpoint.java | 84 +++++++++++++++++++ .../v2/event/GetSubmissionEndpoint.java | 73 ++++++++++++++++ .../v2/project/GetProjectByIdEndpoint.java | 15 ++-- .../v2/project/GetProjectByModIdEndpoint.java | 18 ++-- .../v2/project/GetProjectEndpoint.java | 5 +- .../util/{metadata => }/MetadataUtils.java | 4 +- ...hMetadataUtils.java => ModrinthUtils.java} | 36 ++++++-- 9 files changed, 275 insertions(+), 31 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java rename src/main/java/net/modgarden/backend/util/{metadata => }/MetadataUtils.java (97%) rename src/main/java/net/modgarden/backend/util/{metadata/ModrinthMetadataUtils.java => ModrinthUtils.java} (57%) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index e6552b9..5a15312 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -28,6 +28,8 @@ 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.event.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.util.AuthUtil; @@ -129,6 +131,9 @@ public void v2() { get(GetProjectByIdEndpoint::new); get(GetProjectByModIdEndpoint::new); + + get(GetSubmissionByIdEndpoint::new); + get(GetSubmissionByModIdEndpoint::new); } private void get(Supplier endpointSupplier) { diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java new file mode 100644 index 0000000..1c3ebd1 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java @@ -0,0 +1,66 @@ +package net.modgarden.backend.endpoint.v2.event; + +import io.javalin.http.Context; +import net.modgarden.backend.data.event.Submission; +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/event/{event_type_slug}/{event_slug}/id/{submission_id}") +public class GetSubmissionByIdEndpoint extends GetSubmissionEndpoint { + public GetSubmissionByIdEndpoint() { + super("id/{submission_id}"); + } + + @Override + public void handle(@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 submissionId = ctx.pathParam("submission_id").toLowerCase(Locale.ROOT); + + try ( + var connection = this.getDatabaseConnection(); + var eventStatement = connection.prepareStatement(""" + SELECT id + FROM events + WHERE event_type_slug = ? AND slug = ? + """); + var submissionsStatement = connection.prepareStatement(""" + SELECT 1 + FROM submissions + WHERE 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; + } + + String event = eventResult.getString("id"); + + submissionsStatement.setString(1, submissionId); + submissionsStatement.setString(2, event); + var submissionsResult = submissionsStatement.executeQuery(); + + if (!submissionsResult.getBoolean(1)) { + ctx.result("Could not find submission '" + submissionId + "' for event '" + eventSlug + "' for event type '" + eventTypeSlug + "'."); + ctx.status(404); + return; + } + + Submission submission = GetSubmissionEndpoint.getSubmissionFromId(connection, submissionId); + ctx.json(submission); + ctx.status(200); + } + } +} 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..f4abedf --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java @@ -0,0 +1,84 @@ +package net.modgarden.backend.endpoint.v2.event; + +import io.javalin.http.Context; +import net.modgarden.backend.data.event.Submission; +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/event/{event_type_slug}/{event_slug}/mod_id/{mod_id}") +public class GetSubmissionByModIdEndpoint extends GetSubmissionEndpoint { + public GetSubmissionByModIdEndpoint() { + super("mod_id/{mod_id}"); + } + + @SuppressWarnings("DuplicatedCode") + @Override + public void handle(@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 projectMetadataStatement = connection.prepareStatement(""" + SELECT project_id + FROM project_metadata + WHERE mod_id = ? + """); + var submissionsStatement = connection.prepareStatement(""" + SELECT id + FROM submissions + WHERE project_id = ? AND event = ? + """) + ) { + projectMetadataStatement.setString(1, modId); + var projectMetadataResult = projectMetadataStatement.executeQuery(); + + if (!projectMetadataResult.isBeforeFirst()) { + ctx.result("Could not find mod with id '" + modId + "'."); + ctx.status(404); + return; + } + + 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; + } + + 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 = GetSubmissionEndpoint.getSubmissionFromId(connection, submissionId); + ctx.json(submission); + ctx.status(200); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java new file mode 100644 index 0000000..7eda5b7 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java @@ -0,0 +1,73 @@ +package net.modgarden.backend.endpoint.v2.event; + +import io.javalin.http.Context; +import net.modgarden.backend.data.Platform; +import net.modgarden.backend.data.event.Submission; +import net.modgarden.backend.data.event.platform.ModrinthPlatform; +import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.endpoint.v2.project.GetProjectEndpoint; +import net.modgarden.backend.util.ModrinthUtils; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.ResultSet; + +public abstract class GetSubmissionEndpoint extends Endpoint { + public GetSubmissionEndpoint(String path) { + super(2, "event/{event_type_slug}/{event_slug}/" + path); + } + + @Override + public abstract void handle(@NotNull Context ctx) throws Exception; + + public static Submission getSubmissionFromId(@NotNull Connection connection, + @NotNull String submissionId) throws Exception { + try ( + var submissionStatement = connection.prepareStatement(""" + SELECT event, submitted + FROM submissions + WHERE id = ? + """); + var projectStatement = connection.prepareStatement(""" + SELECT id + FROM projects + 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(); + + projectStatement.setString(1, submissionId); + ResultSet projectResult = projectStatement.executeQuery(); + + modrinthSubmissionTypeStatement.setString(1, submissionId); + ResultSet modrinthSubmissionTypeResult = modrinthSubmissionTypeStatement.executeQuery(); + + Platform platform; + // TODO: Implement download URL submission type. + if (modrinthSubmissionTypeResult.isBeforeFirst()) { + String modrinthId = modrinthSubmissionTypeResult.getString("modrinth_id"); + platform = new ModrinthPlatform( + modrinthId, + modrinthSubmissionTypeResult.getString("version_id"), + ModrinthUtils.getSlugFromId(modrinthId) + ); + } else { + throw new RuntimeException("Submission does not have a valid 'platform'."); + } + + return new Submission( + submissionId, + submissionResult.getString("event"), + submissionResult.getLong("submitted"), + GetProjectEndpoint.getProjectFromId(connection, projectResult.getString("id")), + platform + ); + } + } +} 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 index e51bd7e..0c10674 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java @@ -22,7 +22,11 @@ public void handle(@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 = ?") + var projectStatement = connection.prepareStatement(""" + SELECT 1 + FROM projects + WHERE id = ? + """) ) { projectStatement.setString(1, projectId); ResultSet projectResult = projectStatement.executeQuery(); @@ -32,14 +36,7 @@ public void handle(@NotNull Context ctx) throws Exception { return; } - Project project = getProjectFromId(connection, projectId); - - if (project == null) { - ctx.result("Could not create project object from id '" + projectId + "'."); - ctx.status(500); - return; - } - + Project project = GetProjectEndpoint.getProjectFromId(connection, 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 index 7a04cb5..1398ee3 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java @@ -23,23 +23,21 @@ public void handle(@NotNull Context ctx) throws Exception { try ( var connection = this.getDatabaseConnection(); - var projectStatement = connection.prepareStatement("SELECT project_id FROM project_metadata WHERE mod_id = ?") + var projectMetadataStatement = connection.prepareStatement(""" + SELECT project_id + FROM project_metadata + WHERE mod_id = ? + """) ) { - projectStatement.setString(1, modId); - ResultSet projectResult = projectStatement.executeQuery(); + projectMetadataStatement.setString(1, modId); + ResultSet projectResult = projectMetadataStatement.executeQuery(); if (!projectResult.isBeforeFirst()) { ctx.result("Could not find project from mod id '" + modId + "'."); ctx.status(404); return; } String projectId = projectResult.getString("project_id"); - Project project = getProjectFromId(connection, projectId); - - if (project == null) { - ctx.result("Could not create project object from mod id '" + modId + "'."); - ctx.status(500); - return; - } + Project project = GetProjectEndpoint.getProjectFromId(connection, 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 index 12f0e0e..d6f6cd6 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -3,14 +3,12 @@ import io.javalin.http.Context; import net.modgarden.backend.data.event.Project; import net.modgarden.backend.endpoint.Endpoint; -import net.modgarden.backend.endpoint.EndpointPath; import org.jetbrains.annotations.NotNull; import java.sql.Connection; import java.sql.ResultSet; import java.util.*; -@EndpointPath("/v2/auth") public abstract class GetProjectEndpoint extends Endpoint { public GetProjectEndpoint(String path) { super(2, "project/" + path); @@ -19,7 +17,8 @@ public GetProjectEndpoint(String path) { @Override public abstract void handle(@NotNull Context ctx) throws Exception; - protected Project getProjectFromId(@NotNull Connection connection, @NotNull String projectId) throws Exception { + public static Project getProjectFromId(@NotNull Connection connection, + @NotNull String projectId) throws Exception { Map team = new HashMap<>(); Map permissions = new HashMap<>(); List submissions = new ArrayList<>(); diff --git a/src/main/java/net/modgarden/backend/util/metadata/MetadataUtils.java b/src/main/java/net/modgarden/backend/util/MetadataUtils.java similarity index 97% rename from src/main/java/net/modgarden/backend/util/metadata/MetadataUtils.java rename to src/main/java/net/modgarden/backend/util/MetadataUtils.java index 3d8bbbf..960c087 100644 --- a/src/main/java/net/modgarden/backend/util/metadata/MetadataUtils.java +++ b/src/main/java/net/modgarden/backend/util/MetadataUtils.java @@ -1,4 +1,4 @@ -package net.modgarden.backend.util.metadata; +package net.modgarden.backend.util; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -32,7 +32,7 @@ public static Project.Metadata getMetadataFromModrinth(String modrinthProjectId, String modrinthVersionId) throws Exception { ModrinthOAuthClient authClient = OAuthService.MODRINTH.authenticate(); - ExternalData externalData = ModrinthMetadataUtils.getModrinthExternalData(authClient, modrinthProjectId); + ExternalData externalData = ModrinthUtils.getModrinthExternalData(authClient, modrinthProjectId); HttpResponse versionResponse = authClient .get( diff --git a/src/main/java/net/modgarden/backend/util/metadata/ModrinthMetadataUtils.java b/src/main/java/net/modgarden/backend/util/ModrinthUtils.java similarity index 57% rename from src/main/java/net/modgarden/backend/util/metadata/ModrinthMetadataUtils.java rename to src/main/java/net/modgarden/backend/util/ModrinthUtils.java index 796fda6..c8d2259 100644 --- a/src/main/java/net/modgarden/backend/util/metadata/ModrinthMetadataUtils.java +++ b/src/main/java/net/modgarden/backend/util/ModrinthUtils.java @@ -1,8 +1,9 @@ -package net.modgarden.backend.util.metadata; +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.OAuthService; import net.modgarden.backend.oauth.client.ModrinthOAuthClient; import org.jetbrains.annotations.NotNull; @@ -10,10 +11,31 @@ import java.io.InputStreamReader; import java.net.http.HttpResponse; -public class ModrinthMetadataUtils { - protected static MetadataUtils.ExternalData getModrinthExternalData(@NotNull ModrinthOAuthClient oAuthClient, - @NotNull String modrinthProjectId) throws Exception { - HttpResponse projectResponse = oAuthClient +public class ModrinthUtils { + public static String getSlugFromId(String modrinthProjectId) throws Exception { + ModrinthOAuthClient authClient = OAuthService.MODRINTH.authenticate(); + 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 slug from ID."); + } + JsonObject project = potentialProject.getAsJsonObject(); + return project.getAsJsonPrimitive("slug").getAsString(); + } + } + + public static MetadataUtils.ExternalData getModrinthExternalData(@NotNull ModrinthOAuthClient authClient, + @NotNull String modrinthProjectId) throws Exception { + HttpResponse projectResponse = authClient .get( "v3/project/" + modrinthProjectId, HttpResponse.BodyHandlers.ofInputStream() @@ -37,7 +59,7 @@ protected static MetadataUtils.ExternalData getModrinthExternalData(@NotNull Mod } } - protected static String getSourceUrlFromModrinthProject(JsonObject project) { + 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"); @@ -48,7 +70,7 @@ protected static String getSourceUrlFromModrinthProject(JsonObject project) { return null; } - protected static String getBannerUrlFromModrinthProject(JsonObject project) { + public static String getBannerUrlFromModrinthProject(JsonObject project) { if (project.has("gallery")) { for (JsonElement galleryElement : project.getAsJsonArray("gallery")) { JsonObject galleryObject = galleryElement.getAsJsonObject(); From 9a2ea3cdf82b89a6afa2b6f5578e75990a59f565 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 20 Nov 2025 15:52:19 +1100 Subject: [PATCH 57/98] fix: V5ToV6 now applies foreign keys to the new tables. --- .../backend/data/fixer/fix/V5ToV6.java | 232 +++++++++--------- 1 file changed, 117 insertions(+), 115 deletions(-) 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 index da29368..3503dae 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -4,7 +4,7 @@ import net.modgarden.backend.database.function.GenerateNaturalIdFromNumberFunction; import net.modgarden.backend.database.function.GenerateNaturalIdFunction; import net.modgarden.backend.database.function.UnixMillisFunction; -import net.modgarden.backend.util.metadata.MetadataUtils; +import net.modgarden.backend.util.MetadataUtils; import org.jetbrains.annotations.Nullable; import org.sqlite.Function; @@ -38,7 +38,6 @@ protected void xFunc() throws SQLException { statement.addBatch("PRAGMA foreign_keys = ON"); - statement.addBatch("ALTER TABLE users RENAME TO users_old"); statement.addBatch(""" CREATE TABLE IF NOT EXISTS users ( @@ -83,14 +82,99 @@ 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_metadata ( + project_id TEXT UNIQUE NOT NULL, + mod_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + source_url TEXT NOT NULL, + icon_url TEXT NOT NULL, + banner_url TEXT, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (project_id) + ) + """); - statement.addBatch("CREATE TABLE submissions_mr AS SELECT * FROM submissions"); + // 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) + ) + """); + statement.addBatch(""" + INSERT INTO submissions (id, event, project_id, submitted) + SELECT id, event, project_id, submitted from submissions_old + """); + statement.addBatch(""" + WITH cnt(i) AS ( + SELECT 1 UNION SELECT i+1 FROM cnt + ) + UPDATE submissions + SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + """); + + // 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 WHERE submissions_mr.project_id = projects.id + SELECT modrinth_id FROM projects_old WHERE submissions_mr.project_id = projects_old.id ) """); @@ -111,25 +195,11 @@ FOREIGN KEY (submission_id) REFERENCES submissions(id) ON UPDATE CASCADE ON DELE PRIMARY KEY (submission_id) ) """); - statement.addBatch("PRAGMA foreign_keys = OFF"); 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("PRAGMA foreign_keys = ON"); - - 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 api_keys ( @@ -194,39 +264,6 @@ FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE CREATE UNIQUE INDEX idx_project_roles_two_ids ON project_roles(project_id, user_id) """); - - 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(""" CREATE TABLE IF NOT EXISTS event_integration_discord ( id TEXT UNIQUE NOT NULL, @@ -240,32 +277,6 @@ INSERT INTO event_integration_discord (id, role_id) SELECT id, discord_role_id FROM events_old """); - - 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) - ) - """); - statement.addBatch(""" - INSERT INTO submissions (id, event, project_id, submitted) - SELECT id, event, project_id, submitted from submissions_old - """); - statement.addBatch(""" - WITH cnt(i) AS ( - SELECT 1 UNION SELECT i+1 FROM cnt - ) - UPDATE submissions - SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) - """); - - statement.addBatch(""" INSERT INTO user_integration_modrinth (user_id, modrinth_id) SELECT id, modrinth_id FROM users_old @@ -411,34 +422,24 @@ WITH cnt(i) AS ( SET slug = clean_slug_mg(slug) """); - statement.addBatch(""" - CREATE TABLE IF NOT EXISTS project_metadata ( - project_id TEXT UNIQUE NOT NULL, - mod_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - source_url TEXT NOT NULL, - icon_url TEXT NOT NULL, - banner_url TEXT, - FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, - PRIMARY KEY (project_id) - ) - """); - statement.executeBatch(); - var modrinthSubmissions = connection.prepareStatement(""" + 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 - """).executeQuery(); - var projectMetadataInsertStatement = connection.prepareStatement("INSERT INTO project_metadata VALUES (?, ?, ?, ?, ?, ?, ?)"); + """); + var projectMetadataInsertStatement = connection.prepareStatement(""" + INSERT INTO project_metadata (project_id, mod_id, name, description, source_url, icon_url, banner_url) + VALUES (?, ?, ?, ?, ?, ?, ?) + """); + var modrinthSubmissionsResult = modrinthSubmissionsStatement.executeQuery(); - if (modrinthSubmissions.isBeforeFirst()) { - while (modrinthSubmissions.next()) { - String projectId = modrinthSubmissions.getString("project_id"); - String modrinthId = modrinthSubmissions.getString("modrinth_id"); - String modrinthVersionId = modrinthSubmissions.getString("version_id"); + 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 modrinthData = MetadataUtils.getMetadataFromModrinth(modrinthId, modrinthVersionId); @@ -456,23 +457,24 @@ PRIMARY KEY (project_id) } } - return dropConnection -> { + return postConnections -> { try { - var dropStatement = dropConnection.createStatement(); - dropStatement.addBatch("PRAGMA foreign_keys = ON"); - dropStatement.addBatch("DROP TABLE submissions_old"); - dropStatement.addBatch("DROP TABLE submissions_mr"); - dropStatement.addBatch("DROP TABLE project_builders"); - dropStatement.addBatch("DROP TABLE project_authors"); - dropStatement.addBatch("DROP TABLE project_roles_temp"); - dropStatement.addBatch("DROP TABLE minecraft_accounts_old"); - dropStatement.addBatch("DROP TABLE award_instances_old"); - dropStatement.addBatch("DROP TABLE team_invites_old"); - dropStatement.addBatch("DROP TABLE projects_old"); - dropStatement.addBatch("DROP TABLE users_old"); - dropStatement.addBatch("DROP TABLE events_old"); - dropStatement.executeBatch(); - } catch (SQLException e) { + 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); } }; From 676d6e8d9a6c2d06ad1fa8ffbd83d14139b57528 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 11:46:50 +1100 Subject: [PATCH 58/98] refactor: Randomize IDs within V5ToV6 instead of making them consistent. --- .../modgarden/backend/ModGardenBackend.java | 6 --- .../net/modgarden/backend/data/NaturalId.java | 39 +++++++--------- .../backend/data/fixer/fix/V5ToV6.java | 44 ++++++++----------- .../GenerateNaturalIdFromNumberFunction.java | 24 ---------- .../function/GenerateNaturalIdFunction.java | 2 +- 5 files changed, 36 insertions(+), 79 deletions(-) delete mode 100644 src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFromNumberFunction.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 5a15312..89d875b 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -13,7 +13,6 @@ import net.modgarden.backend.data.BackendError; import net.modgarden.backend.data.DevelopmentModeData; import net.modgarden.backend.data.Landing; -import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.award.Award; import net.modgarden.backend.data.award.AwardInstance; import net.modgarden.backend.data.event.Event; @@ -21,7 +20,6 @@ import net.modgarden.backend.data.event.Submission; import net.modgarden.backend.data.fixer.DatabaseFixer; import net.modgarden.backend.data.user.User; -import net.modgarden.backend.database.function.GenerateNaturalIdFromNumberFunction; import net.modgarden.backend.database.function.GenerateNaturalIdFunction; import net.modgarden.backend.database.function.UnixMillisFunction; import net.modgarden.backend.endpoint.Endpoint; @@ -72,9 +70,6 @@ public static void main(String[] args) { if ("development".equals(DOTENV.get("env"))) ((ch.qos.logback.classic.Logger)LOG).setLevel(Level.DEBUG); - ModGardenBackend.LOG.debug("1 {}, 4 {}, 26 {}, 29 {}, 52 {}, 53 {}, 79 {}", NaturalId.generateFromNumber(1, 2), NaturalId.generateFromNumber(4, 2), NaturalId.generateFromNumber(26, 2), NaturalId.generateFromNumber(29, 2), NaturalId.generateFromNumber(52, 2), NaturalId.generateFromNumber(53, 2), NaturalId.generateFromNumber(79, 2)); - ModGardenBackend.LOG.debug("1 {}, 4 {}, 26 {}, 29 {}, 52 {}, 53 {}, 79 {}, 675 {}, 676 {}, 677 {}", NaturalId.generateFromNumber(1, 3), NaturalId.generateFromNumber(4, 3), NaturalId.generateFromNumber(26, 3), NaturalId.generateFromNumber(29, 3), NaturalId.generateFromNumber(52, 3), NaturalId.generateFromNumber(53, 3), NaturalId.generateFromNumber(79, 3), NaturalId.generateFromNumber(675, 3), NaturalId.generateFromNumber(676, 3), NaturalId.generateFromNumber(677, 3)); - registerCodec(Landing.class, Landing.CODEC); registerCodec(BackendError.class, BackendError.CODEC); registerCodec(Award.class, Award.DIRECT_CODEC); @@ -378,7 +373,6 @@ PRIMARY KEY (code) """); GenerateNaturalIdFunction.INSTANCE.create(connection); - GenerateNaturalIdFromNumberFunction.INSTANCE.create(connection); UnixMillisFunction.INSTANCE.create(connection); statement.executeBatch(); diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 0295ddf..ab71b36 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -2,12 +2,14 @@ import net.modgarden.backend.ModGardenBackend; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.random.RandomGenerator; +import java.util.random.RandomGeneratorFactory; import java.util.regex.Pattern; public final class NaturalId { @@ -36,41 +38,31 @@ public static boolean isValidLegacy(String id) { return isValid(id) || PATTERN_LEGACY.matcher(id).hasMatch(); } - private static String generateUnchecked(int length) { + private static String generateUnchecked(int length, long seed) { StringBuilder builder = new StringBuilder(); + RandomGenerator random; + random = RandomGeneratorFactory.getDefault().create(seed); for (int i = 0; i < length; i++) { - builder.append(ALPHABET.charAt(RandomGenerator.getDefault().nextInt(ALPHABET.length()))); + builder.append(ALPHABET.charAt(random.nextInt(ALPHABET.length()))); } return builder.toString(); } @NotNull - public static String generateFromNumber(int number, int length) { - int base = ALPHABET.length(); - int iterations = (int) (Math.log(number * base) / Math.log(base)); // what the fuck - - StringBuilder result = new StringBuilder(); - - for (int i = 0; i < iterations; i++) { - result.append(ALPHABET.charAt(number % base)); - number = number / base; - } - - String padding = ("" + ALPHABET.charAt(0)).repeat(length - result.length()); - return padding + result.reverse(); - } - - @NotNull - public static String generate(String table, String key, String key2, int length) throws SQLException { + public static String generate(String table, String key, String key2, + int length, @Nullable Long seed) throws SQLException { String id = null; try (Connection connection1 = ModGardenBackend.createDatabaseConnection()) { while (id == null) { - String naturalId = generateUnchecked(length); + if (seed == null) { + seed = RandomGenerator.getDefault().nextLong(); + } + String naturalId = generateUnchecked(length, seed); PreparedStatement exists; if (key2 != null) { - exists = connection1.prepareStatement("SELECT true FROM " + table + " WHERE ? = ? OR ? = ?"); + exists = connection1.prepareStatement("SELECT 1 FROM " + table + " WHERE ? = ? OR ? = ?"); } else { - exists = connection1.prepareStatement("SELECT true FROM " + table + " WHERE ? = ?"); + exists = connection1.prepareStatement("SELECT 1 FROM " + table + " WHERE ? = ?"); } exists.setString(1, key); exists.setString(2, naturalId); @@ -79,9 +71,10 @@ public static String generate(String table, String key, String key2, int length) exists.setString(4, naturalId); } ResultSet resultSet = exists.executeQuery(); - if (resultSet.isBeforeFirst() && !isReserved(naturalId)) { + if (!resultSet.getBoolean(1) && !isReserved(naturalId)) { id = naturalId; } + seed = null; } } return id; 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 index 3503dae..61f6b54 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,7 +1,8 @@ package net.modgarden.backend.data.fixer.fix; +import com.mojang.datafixers.types.Func; +import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.fixer.DatabaseFix; -import net.modgarden.backend.database.function.GenerateNaturalIdFromNumberFunction; import net.modgarden.backend.database.function.GenerateNaturalIdFunction; import net.modgarden.backend.database.function.UnixMillisFunction; import net.modgarden.backend.util.MetadataUtils; @@ -22,10 +23,21 @@ public V5ToV6() { var statement = connection.createStatement(); GenerateNaturalIdFunction.INSTANCE.create(connection); - GenerateNaturalIdFromNumberFunction.INSTANCE.create(connection); UnixMillisFunction.INSTANCE.create(connection); // temp functions for the datafixer + Function.create( + connection, "generate_natural_id_from_snowflake_id", new Function() { + @Override + protected void xFunc() throws SQLException { + String table = this.value_text(0); + String snowflakeId = this.value_text(1); + long seed = Long.parseLong(snowflakeId); + + this.result(NaturalId.generate(table, "id", null, 5, seed)); + } + } + ); Function.create( connection, "clean_slug_mg", new Function() { @Override @@ -160,11 +172,8 @@ INSERT INTO submissions (id, event, project_id, submitted) SELECT id, event, project_id, submitted from submissions_old """); statement.addBatch(""" - WITH cnt(i) AS ( - SELECT 1 UNION SELECT i+1 FROM cnt - ) UPDATE submissions - SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + SET id = generate_natural_id_from_snowflake_id('submissions', id) """); // Use submissions_old since it has not yet been deleted. @@ -179,11 +188,8 @@ WITH cnt(i) AS ( """); statement.addBatch(""" - WITH cnt(i) AS ( - SELECT 1 UNION SELECT i+1 FROM cnt - ) UPDATE submissions_mr - SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + SET id = generate_natural_id_from_snowflake_id('submissions_mr', id) """); statement.addBatch(""" @@ -363,11 +369,8 @@ INSERT INTO team_invites (code, project_id, user_id, expires, role) """); statement.addBatch(""" - WITH cnt(i) AS ( - SELECT 1 UNION SELECT i+1 FROM cnt - ) UPDATE users - SET id = concat('zzz', generate_natural_id_from_number(ROWID - 1, 2)) + SET id = generate_natural_id_from_snowflake_id('users', id) """); statement.addBatch(""" @@ -400,24 +403,15 @@ INSERT INTO users VALUES ('abcde', 'tiny_pineapple', unix_millis(), 0) """); statement.addBatch(""" - WITH cnt(i) AS ( - SELECT 1 UNION SELECT i+1 FROM cnt - ) UPDATE projects - SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + SET id = generate_natural_id_from_snowflake_id('projects', id) """); statement.addBatch(""" - WITH cnt(i) AS ( - SELECT 1 UNION SELECT i+1 FROM cnt - ) UPDATE events - SET id = concat('zzzz', generate_natural_id_from_number(ROWID - 1, 1)) + SET id = generate_natural_id_from_snowflake_id('events', id) """); statement.addBatch(""" - WITH cnt(i) AS ( - SELECT 1 UNION SELECT i+1 FROM cnt - ) UPDATE events SET slug = clean_slug_mg(slug) """); diff --git a/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFromNumberFunction.java b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFromNumberFunction.java deleted file mode 100644 index d8f58c5..0000000 --- a/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFromNumberFunction.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.modgarden.backend.database.function; - -import net.modgarden.backend.data.NaturalId; -import net.modgarden.backend.database.DatabaseFunction; - -import java.sql.SQLException; - -public class GenerateNaturalIdFromNumberFunction extends DatabaseFunction { - public static final GenerateNaturalIdFromNumberFunction INSTANCE = new GenerateNaturalIdFromNumberFunction(); - - protected GenerateNaturalIdFromNumberFunction() {} - - @Override - protected void xFunc() throws SQLException { - int number = this.value_int(0); - int length = this.value_int(1); - this.result(NaturalId.generateFromNumber(number, length)); - } - - @Override - protected String getName() { - return "generate_natural_id_from_number"; - } -} diff --git a/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java index b0c9da1..95737f5 100644 --- a/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java +++ b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java @@ -16,7 +16,7 @@ protected void xFunc() throws SQLException { 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)); + this.result(NaturalId.generate(table, key, key2, length, null)); } @Override From 6069fe02bafbfbc767dcf36b12bfd34dbb8cc356 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 11:48:51 +1100 Subject: [PATCH 59/98] fix: Fix window in which an exception could happen when looking up something when starting up the backend. --- .../modgarden/backend/ModGardenBackend.java | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 89d875b..a24c620 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -70,6 +70,24 @@ public static void main(String[] args) { if ("development".equals(DOTENV.get("env"))) ((ch.qos.logback.classic.Logger)LOG).setLevel(Level.DEBUG); + Landing.createInstance(); + + try { + boolean createdFile = new File("./database.db").createNewFile(); + DatabaseFixer.createFixers(); + if (createdFile) { + createDatabaseContents(); + updateSchemaVersion(); + LOG.debug("Successfully created database file."); + } + DatabaseFixer.fixDatabase(); + if (!createdFile) { + updateSchemaVersion(); + } + } catch (IOException ex) { + LOG.error("Failed to create database file.", ex); + } + registerCodec(Landing.class, Landing.CODEC); registerCodec(BackendError.class, BackendError.CODEC); registerCodec(Award.class, Award.DIRECT_CODEC); @@ -82,7 +100,6 @@ public static void main(String[] args) { registerCodec(GenerateKeyEndpoint.Response.class, GenerateKeyEndpoint.Response.CODEC); registerCodec(ListKeysEndpoint.Response.class, ListKeysEndpoint.Response.CODEC); - Landing.createInstance(); AuthUtil.clearTokensEachFifteenMinutes(); Javalin app = Javalin.create(config -> config.jsonMapper(createDFUMapper())); @@ -100,23 +117,6 @@ public static void main(String[] args) { app.start(7070); LOG.info("Mod Garden Backend Started!"); - - - try { - boolean createdFile = new File("./database.db").createNewFile(); - DatabaseFixer.createFixers(); - if (createdFile) { - createDatabaseContents(); - updateSchemaVersion(); - LOG.debug("Successfully created database file."); - } - DatabaseFixer.fixDatabase(); - if (!createdFile) { - updateSchemaVersion(); - } - } catch (IOException ex) { - LOG.error("Failed to create database file.", ex); - } } public void v2() { From 51fe3ae5302de0432eaa5a92e0942a62ae610628 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 12:16:40 +1100 Subject: [PATCH 60/98] tweak: Use fabric.mod.json's contact field with the external source URL as a fallback. --- .../net/modgarden/backend/util/MetadataUtils.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/modgarden/backend/util/MetadataUtils.java b/src/main/java/net/modgarden/backend/util/MetadataUtils.java index 960c087..e3dd6ce 100644 --- a/src/main/java/net/modgarden/backend/util/MetadataUtils.java +++ b/src/main/java/net/modgarden/backend/util/MetadataUtils.java @@ -103,7 +103,6 @@ public static Project.Metadata getMetadataFromFabricModJson(@NotNull URI jarUri, String name = fmj.getAsJsonPrimitive("name").getAsString(); String description = fmj.getAsJsonPrimitive("description").getAsString(); - String sourceUrl = getFmjSourceUrl(fmj, externalData); // TODO: Handle Icon and Banner Uploads to CDN. String iconUrl = "placeholder"; @@ -141,21 +140,21 @@ private static InputStream getFmjAsStream(JarFile file) throws Exception { } 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(); } } + if (data.externalSourceUrl() != null) { + return data.externalSourceUrl(); + } throw new RuntimeException("Could not find source URL from either fabric.mod.json or external data."); } public record ExternalData(@Nullable String externalSourceUrl, - @Nullable String externalIconUrl, - @Nullable String externalBannerUrl) { + @Nullable String externalIconUrl, + @Nullable String externalBannerUrl) { } } From 0fa363c0973d30643301c238c156d08accf89166 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 20:44:06 +1100 Subject: [PATCH 61/98] feat: Add @EndpointPath annotation to GetProjectEndpoint and GetSubmissionEndpoint. --- .../backend/endpoint/v2/event/GetSubmissionEndpoint.java | 2 ++ .../backend/endpoint/v2/project/GetProjectEndpoint.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java index 7eda5b7..6e9fedc 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java @@ -5,6 +5,7 @@ import net.modgarden.backend.data.event.Submission; import net.modgarden.backend.data.event.platform.ModrinthPlatform; import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.endpoint.EndpointPath; import net.modgarden.backend.endpoint.v2.project.GetProjectEndpoint; import net.modgarden.backend.util.ModrinthUtils; import org.jetbrains.annotations.NotNull; @@ -12,6 +13,7 @@ import java.sql.Connection; import java.sql.ResultSet; +@EndpointPath("/v2/event/{event_type_slug}/{event_slug}") public abstract class GetSubmissionEndpoint extends Endpoint { public GetSubmissionEndpoint(String path) { super(2, "event/{event_type_slug}/{event_slug}/" + path); 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 index d6f6cd6..cffe390 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -3,12 +3,14 @@ import io.javalin.http.Context; import net.modgarden.backend.data.event.Project; import net.modgarden.backend.endpoint.Endpoint; +import net.modgarden.backend.endpoint.EndpointPath; import org.jetbrains.annotations.NotNull; import java.sql.Connection; import java.sql.ResultSet; import java.util.*; +@EndpointPath("/v2/project") public abstract class GetProjectEndpoint extends Endpoint { public GetProjectEndpoint(String path) { super(2, "project/" + path); From ceba12d6dfe9bf31ac558fa8c21bdb2746fb3605 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 20:55:41 +1100 Subject: [PATCH 62/98] refactor: Hardcode 'type' field as it only has one option for now. --- .../modgarden/backend/data/event/Project.java | 43 ++++++------------- .../v2/project/GetProjectEndpoint.java | 2 - 2 files changed, 12 insertions(+), 33 deletions(-) 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 adcaa0b..add653c 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -18,7 +18,6 @@ // TODO: Allow creating organisations, allow projects to be attributed to an organisation. public record Project(String id, - Type type, Metadata metadata, Map team, Map permissions, @@ -26,7 +25,7 @@ public record Project(String id, Map ext) { public static final Codec DIRECT_CODEC = Codec.lazyInitialized(() -> RecordCodecBuilder.create(inst -> inst.group( Codec.STRING.fieldOf("id").forGetter(Project::id), - Type.CODEC.fieldOf("type").forGetter(Project::type), + Codec.STRING.fieldOf("type").forGetter(_ -> "mod"), // TODO: Unhardcode this from mod. 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), @@ -35,6 +34,17 @@ public record Project(String id, ).apply(inst, Project::new))); public static final Codec ID_CODEC = Codec.STRING.validate(Project::validate); + // TODO: Remove this as soon as 'type' is no longer hardcoded. + private Project(String id, + String _unused, + Metadata metadata, + Map team, + Map permissions, + List submissions, + Map ex) { + this(id, metadata, team, permissions, submissions, ex); + } + private static DataResult validate(String id) { try (Connection connection = ModGardenBackend.createDatabaseConnection(); PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM projects WHERE id = ?")) { @@ -72,33 +82,4 @@ private Optional bannerUrlAsOptional() { return Optional.ofNullable(bannerUrl); } } - - public enum Type { - MOD("mod"),; - - public static final Codec CODEC = Codec.STRING.comapFlatMap(string -> { - Type type = fromString(string); - return type == null ? DataResult.error(() -> "Could not find project type '" + string + "'.") : - DataResult.success(type); - }, Type::getName); - - private final String name; - - Type(String name) { - this.name = name; - } - - public static Type fromString(String value) { - for (Type type : Type.values()) { - if (type.getName().equals(value)) { - return type; - } - } - return null; - } - - public String getName() { - return name; - } - } } 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 index cffe390..4ee0632 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -48,8 +48,6 @@ public static Project getProjectFromId(@NotNull Connection connection, return new Project( projectId, - // TODO: Add project types to database. - Project.Type.MOD, new Project.Metadata( projectMetadataResult.getString("mod_id"), projectMetadataResult.getString("name"), From 481f4ce791626232a236850f042d71a6dcbeff5c Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 21:21:22 +1100 Subject: [PATCH 63/98] feat: Add delete project endpoint. --- .../modgarden/backend/ModGardenBackend.java | 2 + .../v2/AuthorizedProjectEndpoint.java | 18 ++++++++ .../v2/project/DeleteProjectEndpoint.java | 41 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index a24c620..2ca56ce 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -28,6 +28,7 @@ import net.modgarden.backend.endpoint.v2.auth.ListKeysEndpoint; import net.modgarden.backend.endpoint.v2.event.GetSubmissionByIdEndpoint; import net.modgarden.backend.endpoint.v2.event.GetSubmissionByModIdEndpoint; +import net.modgarden.backend.endpoint.v2.project.DeleteProjectEndpoint; import net.modgarden.backend.endpoint.v2.project.GetProjectByIdEndpoint; import net.modgarden.backend.endpoint.v2.project.GetProjectByModIdEndpoint; import net.modgarden.backend.util.AuthUtil; @@ -124,6 +125,7 @@ public void v2() { delete(DeleteKeyEndpoint::new); get(ListKeysEndpoint::new); + delete(DeleteProjectEndpoint::new); get(GetProjectByIdEndpoint::new); get(GetProjectByModIdEndpoint::new); 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..a448c72 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.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/project") +public abstract class AuthorizedProjectEndpoint extends AuthorizedEndpoint { + public AuthorizedProjectEndpoint(String path, PermissionScope permissionScope, boolean hasBody) { + super(2, "project/" + path, permissionScope, hasBody); + } + + @Override + public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; +} 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..ab7d3fe --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -0,0 +1,41 @@ +package net.modgarden.backend.endpoint.v2.project; + +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.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; + +@EndpointMethod(DELETE) +@EndpointPath("/v2/project/{project_id}/delete") +public class DeleteProjectEndpoint extends AuthorizedProjectEndpoint { + public DeleteProjectEndpoint() { + super("{project_id}/delete", PermissionScope.PROJECT, false); + } + + @Override + public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + Permissions userPermissions = getDatabaseAccess() + .getUserPermissions(userId) + .unwrap(ctx); + if (userPermissions == null || !scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !userPermissions.hasPermissions(Permission.MODERATE_PROJECTS)) return; + + String projectId = ctx.pathParam("project_id"); + + try ( + var connection = this.getDatabaseConnection(); + var statement = connection.prepareStatement(""" + DELETE FROM projects + WHERE id = ? + """) + ) { + statement.setString(1, projectId); + statement.executeUpdate(); + } + } +} From 6164331813dc11040f4908afb03362ef81117dfc Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 21:24:03 +1100 Subject: [PATCH 64/98] feat: Add edit event permission. --- src/main/java/net/modgarden/backend/data/Permission.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index a71efbf..afd32c7 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -26,7 +26,9 @@ public enum Permission { /// 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),; + MANAGE_CDN(0x80, "manage_cdn", USER), + // Edit events and hide them. + EDIT_EVENT(0x100, "edit_event", USER); /// The default permissions that all users have. /// From 524de4379584ceb49aae41f8d1565bbc43e3aa59 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 21:43:53 +1100 Subject: [PATCH 65/98] refactor: Refactor permission check in DeleteProjectEndpoint to return a 403 on failure. --- .../backend/endpoint/v2/project/DeleteProjectEndpoint.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index ab7d3fe..6997a81 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -20,10 +20,15 @@ public DeleteProjectEndpoint() { @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode Permissions userPermissions = getDatabaseAccess() .getUserPermissions(userId) .unwrap(ctx); - if (userPermissions == null || !scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !userPermissions.hasPermissions(Permission.MODERATE_PROJECTS)) return; + if (userPermissions == null || !scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !userPermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { + ctx.status(403); + ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); + return; + } String projectId = ctx.pathParam("project_id"); From 617b85fb2b5b6080f3b79b700e7bec306a484705 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Fri, 21 Nov 2025 21:44:22 +1100 Subject: [PATCH 66/98] feat: Add delete submission endpoint. --- .../modgarden/backend/ModGardenBackend.java | 4 +- .../v2/AuthorizedSubmissionEndpoint.java | 18 ++++++++ .../submission/DeleteSubmissionEndpoint.java | 46 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 2ca56ce..4d07294 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -26,11 +26,12 @@ 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.DeleteProjectEndpoint; import net.modgarden.backend.endpoint.v2.event.GetSubmissionByIdEndpoint; import net.modgarden.backend.endpoint.v2.event.GetSubmissionByModIdEndpoint; -import net.modgarden.backend.endpoint.v2.project.DeleteProjectEndpoint; import net.modgarden.backend.endpoint.v2.project.GetProjectByIdEndpoint; import net.modgarden.backend.endpoint.v2.project.GetProjectByModIdEndpoint; +import net.modgarden.backend.endpoint.v2.submission.DeleteSubmissionEndpoint; import net.modgarden.backend.util.AuthUtil; import net.modgarden.backend.util.OrderCorrectedRecordCodec; import org.jetbrains.annotations.NotNull; @@ -129,6 +130,7 @@ public void v2() { get(GetProjectByIdEndpoint::new); get(GetProjectByModIdEndpoint::new); + delete(DeleteSubmissionEndpoint::new); get(GetSubmissionByIdEndpoint::new); get(GetSubmissionByModIdEndpoint::new); } 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..3b9092f --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.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/submission") +public abstract class AuthorizedSubmissionEndpoint extends AuthorizedEndpoint { + public AuthorizedSubmissionEndpoint(String path, PermissionScope permissionScope, boolean hasBody) { + super(2, "submission/" + path, permissionScope, hasBody); + } + + @Override + public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; +} 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..1a9d213 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java @@ -0,0 +1,46 @@ +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 static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; + +@EndpointMethod(DELETE) +@EndpointPath("/v2/submission/{submission_id}/delete") +public class DeleteSubmissionEndpoint extends AuthorizedSubmissionEndpoint { + public DeleteSubmissionEndpoint() { + super("{submission_id}/delete", PermissionScope.PROJECT, false); + } + + @Override + public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + Permissions userPermissions = getDatabaseAccess() + .getUserPermissions(userId) + .unwrap(ctx); + if (userPermissions == null || !scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !userPermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { + ctx.status(403); + ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); + 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(); + } + } +} From 7ec83bb65eb70439e334c7d8a3c15fb13c1a6327 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 01:07:38 +1100 Subject: [PATCH 67/98] feat: Add has_permissions function and refactor database function registration. --- .../modgarden/backend/ModGardenBackend.java | 17 +++++++++---- .../backend/data/fixer/fix/V5ToV6.java | 6 ----- .../function/HasPermissionsFunction.java | 24 +++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/database/function/HasPermissionsFunction.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 4d07294..c1b2199 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -21,6 +21,7 @@ import net.modgarden.backend.data.fixer.DatabaseFixer; 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; @@ -31,6 +32,9 @@ 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.team.AddTeamMemberEndpoint; +import net.modgarden.backend.endpoint.v2.project.team.RemoveTeamMemberEndpoint; +import net.modgarden.backend.endpoint.v2.project.team.SetTeamMemberRoleEndpoint; import net.modgarden.backend.endpoint.v2.submission.DeleteSubmissionEndpoint; import net.modgarden.backend.util.AuthUtil; import net.modgarden.backend.util.OrderCorrectedRecordCodec; @@ -155,11 +159,19 @@ private void delete(Supplier endpointSupplier) { this.app.delete(endpoint.getPath(), endpoint); } + public static void registerDatabaseFunctions(Connection connection) throws SQLException { + GenerateNaturalIdFunction.INSTANCE.create(connection); + HasPermissionsFunction.INSTANCE.create(connection); + UnixMillisFunction.INSTANCE.create(connection); + } + public static Connection createDatabaseConnection() throws SQLException { String url = "jdbc:sqlite:database.db"; Properties props = new Properties(); props.setProperty("foreign_keys", "true"); - return DriverManager.getConnection(url, props); + Connection connection = DriverManager.getConnection(url, props); + registerDatabaseFunctions(connection); + return connection; } private static void createDatabaseContents() { @@ -376,9 +388,6 @@ PRIMARY KEY (code) ) """); - GenerateNaturalIdFunction.INSTANCE.create(connection); - UnixMillisFunction.INSTANCE.create(connection); - statement.executeBatch(); } catch (SQLException ex) { LOG.error("Failed to create database tables. ", ex); 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 index 61f6b54..1946ac5 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,10 +1,7 @@ package net.modgarden.backend.data.fixer.fix; -import com.mojang.datafixers.types.Func; import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.fixer.DatabaseFix; -import net.modgarden.backend.database.function.GenerateNaturalIdFunction; -import net.modgarden.backend.database.function.UnixMillisFunction; import net.modgarden.backend.util.MetadataUtils; import org.jetbrains.annotations.Nullable; import org.sqlite.Function; @@ -22,9 +19,6 @@ public V5ToV6() { public @Nullable Consumer fix(Connection connection) throws SQLException { var statement = connection.createStatement(); - GenerateNaturalIdFunction.INSTANCE.create(connection); - UnixMillisFunction.INSTANCE.create(connection); - // temp functions for the datafixer Function.create( connection, "generate_natural_id_from_snowflake_id", new Function() { 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"; + } +} From 898f2a627390835e18c94c876671dffb328176f3 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 01:08:51 +1100 Subject: [PATCH 68/98] refactor: Modify path for delete endpoints for project and submissions. --- .../endpoint/v2/project/DeleteProjectEndpoint.java | 9 +++------ .../endpoint/v2/submission/DeleteSubmissionEndpoint.java | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) 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 index 6997a81..6e04e0a 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -12,19 +12,16 @@ import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; @EndpointMethod(DELETE) -@EndpointPath("/v2/project/{project_id}/delete") +@EndpointPath("/v2/project/{project_id}") public class DeleteProjectEndpoint extends AuthorizedProjectEndpoint { public DeleteProjectEndpoint() { - super("{project_id}/delete", PermissionScope.PROJECT, false); + super("{project_id}", PermissionScope.ALL, false); } @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - Permissions userPermissions = getDatabaseAccess() - .getUserPermissions(userId) - .unwrap(ctx); - if (userPermissions == null || !scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !userPermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { + if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { ctx.status(403); ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); return; 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 index 1a9d213..d7b2ae9 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java @@ -12,19 +12,16 @@ import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; @EndpointMethod(DELETE) -@EndpointPath("/v2/submission/{submission_id}/delete") +@EndpointPath("/v2/submission/{submission_id}") public class DeleteSubmissionEndpoint extends AuthorizedSubmissionEndpoint { public DeleteSubmissionEndpoint() { - super("{submission_id}/delete", PermissionScope.PROJECT, false); + super("{submission_id}", PermissionScope.ALL, false); } @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - Permissions userPermissions = getDatabaseAccess() - .getUserPermissions(userId) - .unwrap(ctx); - if (userPermissions == null || !scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !userPermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { + if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { ctx.status(403); ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); return; From f0d87e69921d7ebbf56f46238c7786550db06986 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 01:10:00 +1100 Subject: [PATCH 69/98] feat: Add most of the project team member related endpoints. --- .../modgarden/backend/ModGardenBackend.java | 4 +- .../project/team/AddTeamMemberEndpoint.java | 59 +++++++++++++++ .../team/RemoveTeamMemberEndpoint.java | 72 +++++++++++++++++++ .../team/SetTeamMemberRoleEndpoint.java | 60 ++++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index c1b2199..b92de02 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -102,7 +102,6 @@ public static void main(String[] args) { registerCodec(Submission.class, Submission.DIRECT_CODEC); registerCodec(User.class, User.DIRECT_CODEC); registerCodec(AwardInstance.FullAwardData.class, AwardInstance.FullAwardData.CODEC); - registerCodec(GenerateKeyEndpoint.Request.class, GenerateKeyEndpoint.Request.CODEC); registerCodec(GenerateKeyEndpoint.Response.class, GenerateKeyEndpoint.Response.CODEC); registerCodec(ListKeysEndpoint.Response.class, ListKeysEndpoint.Response.CODEC); @@ -130,6 +129,9 @@ public void v2() { delete(DeleteKeyEndpoint::new); get(ListKeysEndpoint::new); + put(AddTeamMemberEndpoint::new); + delete(RemoveTeamMemberEndpoint::new); + put(SetTeamMemberRoleEndpoint::new); delete(DeleteProjectEndpoint::new); get(GetProjectByIdEndpoint::new); get(GetProjectByModIdEndpoint::new); diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java new file mode 100644 index 0000000..2f70665 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java @@ -0,0 +1,59 @@ +package net.modgarden.backend.endpoint.v2.project.team; + +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.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; + +import java.sql.ResultSet; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.PUT; + +@EndpointMethod(PUT) +@EndpointPath("/v2/project/{project_id}/team/{user_id}") +public class AddTeamMemberEndpoint extends AuthorizedProjectEndpoint { + public AddTeamMemberEndpoint() { + super("{project_id}/team/{user_id}", PermissionScope.ALL, true); + } + + @Override + public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { + ctx.status(403); + ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); + return; + } + + String projectId = ctx.pathParam("project_id"); + String memberUserId = ctx.pathParam("user_id"); + + try ( + var connection = this.getDatabaseConnection(); + var checkStatement = connection.prepareStatement(""" + SELECT 1 + FROM project_roles + WHERE project_id = ? AND user_id = ? + """); + var insertStatement = connection.prepareStatement(""" + INSERT INTO project_roles (project_id, user_id) + VALUES (?, ?) + """) + ) { + checkStatement.setString(1, projectId); + checkStatement.setString(2, memberUserId); + ResultSet checkResult = checkStatement.executeQuery(); + + // Check if the user is already a member of the project to avoid an exception being thrown. + if (checkResult.getBoolean(1)) return; + + insertStatement.setString(1, projectId); + insertStatement.setString(2, memberUserId); + insertStatement.executeUpdate(); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java new file mode 100644 index 0000000..9612de5 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java @@ -0,0 +1,72 @@ +package net.modgarden.backend.endpoint.v2.project.team; + +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.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}/team/{user_id}") +public class RemoveTeamMemberEndpoint extends AuthorizedProjectEndpoint { + public RemoveTeamMemberEndpoint() { + super("{project_id}/team/{user_id}", PermissionScope.ALL, true); + } + + @Override + public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { + ctx.status(403); + ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); + return; + } + + String projectId = ctx.pathParam("project_id"); + String memberUserId = ctx.pathParam("user_id"); + + try ( + var connection = this.getDatabaseConnection(); + var permissionCheckStatement = 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, 256) + """); + var deleteStatement = connection.prepareStatement(""" + DELETE FROM project_roles + WHERE project_id = ? AND user_id = ? + """) + ) { + permissionCheckStatement.setString(1, projectId); + permissionCheckStatement.setString(2, memberUserId); + ResultSet memberPermissionsResult = permissionCheckStatement.executeQuery(); + Permissions memberPermissions = new Permissions(memberPermissionsResult.getLong(1)); + + // TODO: Figure out if team members that can edit project can remove project administrators... That feels unintended. + boolean memberCanEditProject = memberPermissions.hasPermissions(Permission.EDIT_PROJECT); + + // 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. + if (memberCanEditProject) { + permissionCountStatement.setString(1, projectId); + ResultSet permissionCountResult = permissionCountStatement.executeQuery(); + if (permissionCountResult.getInt(1) < 2) return; + } + + deleteStatement.setString(1, projectId); + deleteStatement.setString(2, memberUserId); + deleteStatement.executeUpdate(); + } + } +} diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java new file mode 100644 index 0000000..d6a4866 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java @@ -0,0 +1,60 @@ +package net.modgarden.backend.endpoint.v2.project.team; + +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.AuthorizedProjectEndpoint; +import org.jetbrains.annotations.NotNull; + +import static net.modgarden.backend.endpoint.EndpointMethod.Method.PUT; + +@EndpointMethod(PUT) +@EndpointPath("/v2/project/{project_id}/team/{user_id}/set_role") +public class SetTeamMemberRoleEndpoint extends AuthorizedProjectEndpoint { + public SetTeamMemberRoleEndpoint() { + super("{project_id}/team/{user_id}/set_role", PermissionScope.ALL, true); + } + + @Override + public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + //noinspection DuplicatedCode + if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { + ctx.status(403); + ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); + return; + } + + String projectId = ctx.pathParam("project_id"); + String memberUserId = ctx.pathParam("user_id"); + + Request request = decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + + if (request == null) return; + + try ( + var connection = this.getDatabaseConnection(); + var statement = connection.prepareStatement(""" + UPDATE project_roles + SET role_name = ? + WHERE project_id = ? AND user_id = ? + """) + ) { + statement.setString(1, request.roleName()); + statement.setString(2, projectId); + statement.setString(3, memberUserId); + statement.executeUpdate(); + } + } + + public record Request(String roleName) { + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.STRING.fieldOf("role_name").forGetter(Request::roleName) + ).apply(inst, Request::new)); + } +} From cd368d9b3d8f55e66f239dd80ede88158189981a Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 09:32:15 +1100 Subject: [PATCH 70/98] refactor: Rename project and submission getting endpoints. --- .../v2/event/GetSubmissionByIdEndpoint.java | 28 +++---------------- .../event/GetSubmissionByModIdEndpoint.java | 8 +++--- .../v2/event/GetSubmissionEndpoint.java | 6 ++-- .../v2/project/GetProjectByIdEndpoint.java | 6 ++-- .../v2/project/GetProjectByModIdEndpoint.java | 2 +- 5 files changed, 14 insertions(+), 36 deletions(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java index 1c3ebd1..d9e0f24 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java @@ -11,49 +11,29 @@ import static net.modgarden.backend.endpoint.EndpointMethod.Method.GET; @EndpointMethod(GET) -@EndpointPath("/v2/event/{event_type_slug}/{event_slug}/id/{submission_id}") +@EndpointPath("/v2/submission/{submission_id}") public class GetSubmissionByIdEndpoint extends GetSubmissionEndpoint { public GetSubmissionByIdEndpoint() { - super("id/{submission_id}"); + super("submission/{submission_id}"); } @Override public void handle(@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 submissionId = ctx.pathParam("submission_id").toLowerCase(Locale.ROOT); try ( var connection = this.getDatabaseConnection(); - var eventStatement = connection.prepareStatement(""" - SELECT id - FROM events - WHERE event_type_slug = ? AND slug = ? - """); var submissionsStatement = connection.prepareStatement(""" SELECT 1 FROM submissions - WHERE id = ? AND event = ? + WHERE id = ? """) ) { - 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; - } - - String event = eventResult.getString("id"); - submissionsStatement.setString(1, submissionId); - submissionsStatement.setString(2, event); var submissionsResult = submissionsStatement.executeQuery(); if (!submissionsResult.getBoolean(1)) { - ctx.result("Could not find submission '" + submissionId + "' for event '" + eventSlug + "' for event type '" + eventTypeSlug + "'."); + ctx.result("Could not find submission '" + submissionId + "'"); ctx.status(404); return; } 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 index f4abedf..739888b 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java @@ -14,7 +14,7 @@ @EndpointPath("/v2/event/{event_type_slug}/{event_slug}/mod_id/{mod_id}") public class GetSubmissionByModIdEndpoint extends GetSubmissionEndpoint { public GetSubmissionByModIdEndpoint() { - super("mod_id/{mod_id}"); + super("event/{event_type_slug}/{event_slug}/mod_id/{mod_id}"); } @SuppressWarnings("DuplicatedCode") @@ -46,7 +46,7 @@ public void handle(@NotNull Context ctx) throws Exception { var projectMetadataResult = projectMetadataStatement.executeQuery(); if (!projectMetadataResult.isBeforeFirst()) { - ctx.result("Could not find mod with id '" + modId + "'."); + ctx.result("Could not find mod with id '" + modId + "'"); ctx.status(404); return; } @@ -56,7 +56,7 @@ public void handle(@NotNull Context ctx) throws Exception { var eventResult = eventStatement.executeQuery(); if (!eventResult.isBeforeFirst()) { - ctx.result("Could not find event '" + eventSlug + "' for event type '" + eventTypeSlug + "'."); + ctx.result("Could not find event '" + eventSlug + "' for event type '" + eventTypeSlug + "'"); ctx.status(404); return; } @@ -71,7 +71,7 @@ public void handle(@NotNull Context ctx) throws Exception { 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.result("Could not find submission for mod with ID '" + modId + "' for event '" + eventSlug + "' for event type '" + eventTypeSlug + "'"); ctx.status(404); return; } diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java index 6e9fedc..d680d23 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java @@ -5,7 +5,6 @@ import net.modgarden.backend.data.event.Submission; import net.modgarden.backend.data.event.platform.ModrinthPlatform; import net.modgarden.backend.endpoint.Endpoint; -import net.modgarden.backend.endpoint.EndpointPath; import net.modgarden.backend.endpoint.v2.project.GetProjectEndpoint; import net.modgarden.backend.util.ModrinthUtils; import org.jetbrains.annotations.NotNull; @@ -13,10 +12,9 @@ import java.sql.Connection; import java.sql.ResultSet; -@EndpointPath("/v2/event/{event_type_slug}/{event_slug}") public abstract class GetSubmissionEndpoint extends Endpoint { public GetSubmissionEndpoint(String path) { - super(2, "event/{event_type_slug}/{event_slug}/" + path); + super(2, path); } @Override @@ -60,7 +58,7 @@ public static Submission getSubmissionFromId(@NotNull Connection connection, ModrinthUtils.getSlugFromId(modrinthId) ); } else { - throw new RuntimeException("Submission does not have a valid 'platform'."); + throw new RuntimeException("Submission does not have a valid 'platform'"); } return new Submission( 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 index 0c10674..e69fdbe 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java @@ -11,10 +11,10 @@ import static net.modgarden.backend.endpoint.EndpointMethod.Method.GET; @EndpointMethod(GET) -@EndpointPath("/v2/project/id/{project_id}") +@EndpointPath("/v2/project/{project_id}") public class GetProjectByIdEndpoint extends GetProjectEndpoint { public GetProjectByIdEndpoint() { - super("id/{project_id}"); + super("{project_id}"); } @Override @@ -31,7 +31,7 @@ public void handle(@NotNull Context ctx) throws Exception { projectStatement.setString(1, projectId); ResultSet projectResult = projectStatement.executeQuery(); if (!projectResult.isBeforeFirst()) { - ctx.result("Could not find project from id '" + projectId + "'."); + ctx.result("Could not find project from id '" + projectId + "'"); ctx.status(404); return; } 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 index 1398ee3..62bc541 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java @@ -32,7 +32,7 @@ public void handle(@NotNull Context ctx) throws Exception { projectMetadataStatement.setString(1, modId); ResultSet projectResult = projectMetadataStatement.executeQuery(); if (!projectResult.isBeforeFirst()) { - ctx.result("Could not find project from mod id '" + modId + "'."); + ctx.result("Could not find project from mod id '" + modId + "'"); ctx.status(404); return; } From db3796df0e62b4325ccb21fa895b8bd32cae7428 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 09:41:57 +1100 Subject: [PATCH 71/98] fix: Make rerolled seeded ID consistent when an existing ID exists. --- .../java/net/modgarden/backend/data/NaturalId.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index ab71b36..2b89f43 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -38,10 +38,10 @@ public static boolean isValidLegacy(String id) { return isValid(id) || PATTERN_LEGACY.matcher(id).hasMatch(); } - private static String generateUnchecked(int length, long seed) { + private static String generateUnchecked(int length, @Nullable Long seed) { StringBuilder builder = new StringBuilder(); - RandomGenerator random; - random = RandomGeneratorFactory.getDefault().create(seed); + RandomGenerator random = seed != null ? RandomGeneratorFactory.getDefault().create(seed) : + RandomGenerator.getDefault(); for (int i = 0; i < length; i++) { builder.append(ALPHABET.charAt(random.nextInt(ALPHABET.length()))); } @@ -54,9 +54,6 @@ public static String generate(String table, String key, String key2, String id = null; try (Connection connection1 = ModGardenBackend.createDatabaseConnection()) { while (id == null) { - if (seed == null) { - seed = RandomGenerator.getDefault().nextLong(); - } String naturalId = generateUnchecked(length, seed); PreparedStatement exists; if (key2 != null) { @@ -74,7 +71,9 @@ public static String generate(String table, String key, String key2, if (!resultSet.getBoolean(1) && !isReserved(naturalId)) { id = naturalId; } - seed = null; + if (seed != null) { + seed = RandomGeneratorFactory.getDefault().create(seed).nextLong(); + } } } return id; From f2eb68c2f200f000c71c9e22f56ecf80bc29a2cd Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 09:47:25 +1100 Subject: [PATCH 72/98] fix: Remove seed field from NaturalId generation. --- .../java/net/modgarden/backend/data/NaturalId.java | 14 ++++---------- .../modgarden/backend/data/fixer/fix/V5ToV6.java | 13 ------------- .../function/GenerateNaturalIdFunction.java | 2 +- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 2b89f43..bf72afc 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -2,14 +2,12 @@ import net.modgarden.backend.ModGardenBackend; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.random.RandomGenerator; -import java.util.random.RandomGeneratorFactory; import java.util.regex.Pattern; public final class NaturalId { @@ -38,10 +36,9 @@ public static boolean isValidLegacy(String id) { return isValid(id) || PATTERN_LEGACY.matcher(id).hasMatch(); } - private static String generateUnchecked(int length, @Nullable Long seed) { + private static String generateUnchecked(int length) { StringBuilder builder = new StringBuilder(); - RandomGenerator random = seed != null ? RandomGeneratorFactory.getDefault().create(seed) : - RandomGenerator.getDefault(); + RandomGenerator random = RandomGenerator.getDefault(); for (int i = 0; i < length; i++) { builder.append(ALPHABET.charAt(random.nextInt(ALPHABET.length()))); } @@ -50,11 +47,11 @@ private static String generateUnchecked(int length, @Nullable Long seed) { @NotNull public static String generate(String table, String key, String key2, - int length, @Nullable Long seed) throws SQLException { + int length) throws SQLException { String id = null; try (Connection connection1 = ModGardenBackend.createDatabaseConnection()) { while (id == null) { - String naturalId = generateUnchecked(length, seed); + String naturalId = generateUnchecked(length); PreparedStatement exists; if (key2 != null) { exists = connection1.prepareStatement("SELECT 1 FROM " + table + " WHERE ? = ? OR ? = ?"); @@ -71,9 +68,6 @@ public static String generate(String table, String key, String key2, if (!resultSet.getBoolean(1) && !isReserved(naturalId)) { id = naturalId; } - if (seed != null) { - seed = RandomGeneratorFactory.getDefault().create(seed).nextLong(); - } } } return id; 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 index 1946ac5..62aa775 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,6 +1,5 @@ package net.modgarden.backend.data.fixer.fix; -import net.modgarden.backend.data.NaturalId; import net.modgarden.backend.data.fixer.DatabaseFix; import net.modgarden.backend.util.MetadataUtils; import org.jetbrains.annotations.Nullable; @@ -20,18 +19,6 @@ public V5ToV6() { var statement = connection.createStatement(); // temp functions for the datafixer - Function.create( - connection, "generate_natural_id_from_snowflake_id", new Function() { - @Override - protected void xFunc() throws SQLException { - String table = this.value_text(0); - String snowflakeId = this.value_text(1); - long seed = Long.parseLong(snowflakeId); - - this.result(NaturalId.generate(table, "id", null, 5, seed)); - } - } - ); Function.create( connection, "clean_slug_mg", new Function() { @Override diff --git a/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java index 95737f5..b0c9da1 100644 --- a/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java +++ b/src/main/java/net/modgarden/backend/database/function/GenerateNaturalIdFunction.java @@ -16,7 +16,7 @@ protected void xFunc() throws SQLException { 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, null)); + this.result(NaturalId.generate(table, key, key2, length)); } @Override From 0648a15112bc2536ad848aaae1fec8c93a5af017 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 09:48:04 +1100 Subject: [PATCH 73/98] refactor: Remove NaturalId#isValidLegacy. --- src/main/java/net/modgarden/backend/data/NaturalId.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index bf72afc..30319d0 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -12,7 +12,6 @@ public final class NaturalId { private static final Pattern PATTERN = Pattern.compile("^[a-z]{5}$"); - private static final Pattern PATTERN_LEGACY = Pattern.compile("[0-9]+"); // 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 @@ -32,10 +31,6 @@ public static boolean isValid(String id) { return PATTERN.matcher(id).hasMatch(); } - public static boolean isValidLegacy(String id) { - return isValid(id) || PATTERN_LEGACY.matcher(id).hasMatch(); - } - private static String generateUnchecked(int length) { StringBuilder builder = new StringBuilder(); RandomGenerator random = RandomGenerator.getDefault(); From 4e6e86aab092856398f58b7409947dc6ebecfba7 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 09:54:40 +1100 Subject: [PATCH 74/98] refactor: Use FMJ contact field as the fallback instead of the external source URL. --- .../java/net/modgarden/backend/util/MetadataUtils.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/modgarden/backend/util/MetadataUtils.java b/src/main/java/net/modgarden/backend/util/MetadataUtils.java index e3dd6ce..884f775 100644 --- a/src/main/java/net/modgarden/backend/util/MetadataUtils.java +++ b/src/main/java/net/modgarden/backend/util/MetadataUtils.java @@ -140,16 +140,16 @@ private static InputStream getFmjAsStream(JarFile file) throws Exception { } 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(); } } - if (data.externalSourceUrl() != null) { - return data.externalSourceUrl(); - } - throw new RuntimeException("Could not find source URL from either fabric.mod.json or external data."); + throw new NullPointerException("Could not find source URL from either fabric.mod.json or external data."); } public record ExternalData(@Nullable String externalSourceUrl, From 0f3895a8ede4b6c1d92cadcb372d81a2db06802c Mon Sep 17 00:00:00 2001 From: sylv256 Date: Fri, 21 Nov 2025 18:02:14 -0500 Subject: [PATCH 75/98] feat: `InternalEndpoint` --- .../backend/endpoint/AuthorizedEndpoint.java | 6 ++++++ .../modgarden/backend/endpoint/Endpoint.java | 5 +++++ .../backend/endpoint/InternalEndpoint.java | 21 +++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 src/main/java/net/modgarden/backend/endpoint/InternalEndpoint.java diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 684cb55..9187db4 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -37,6 +37,12 @@ public AuthorizedEndpoint(int version, String path, PermissionScope permissionSc 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); } diff --git a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java index 7268489..27d21d8 100644 --- a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java @@ -27,6 +27,11 @@ 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 void handle(@NotNull Context ctx) throws Exception { // validate all path params 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); + } +} From aebe5797c615631e5a84ea8f0e18f51318794c1a Mon Sep 17 00:00:00 2001 From: sylv256 Date: Fri, 21 Nov 2025 19:18:33 -0500 Subject: [PATCH 76/98] refactor: deduplicate authorization checks --- .../modgarden/backend/data/Permissions.java | 14 +++++++++++-- .../backend/endpoint/AuthorizedEndpoint.java | 20 ++++++++++++++++--- .../endpoint/v2/auth/DeleteKeyEndpoint.java | 4 ++-- .../endpoint/v2/auth/GenerateKeyEndpoint.java | 4 ++-- .../v2/project/DeleteProjectEndpoint.java | 7 ++----- .../project/team/AddTeamMemberEndpoint.java | 7 ++----- .../team/RemoveTeamMemberEndpoint.java | 7 ++----- .../team/SetTeamMemberRoleEndpoint.java | 7 ++----- .../submission/DeleteSubmissionEndpoint.java | 7 ++----- 9 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/Permissions.java b/src/main/java/net/modgarden/backend/data/Permissions.java index 698b311..e52ebdf 100644 --- a/src/main/java/net/modgarden/backend/data/Permissions.java +++ b/src/main/java/net/modgarden/backend/data/Permissions.java @@ -32,8 +32,14 @@ public Permissions revokePermissions(Permission... permissions) { return this.revokePermissions(new Permissions(permissions)); } - public boolean hasPermissions(Permissions permissions) { - boolean hasPermissions = (permissions.bits & this.bits) == permissions.bits; + 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; } @@ -42,6 +48,10 @@ 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); diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 9187db4..87f4dc9 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -265,18 +265,32 @@ protected void setStatusForbidden(Context ctx) { ctx.status(403); } - protected boolean requirePermissions(Context ctx, Permissions scopePermissions, Permissions permissions) { + 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 requirePermissions(Context ctx, Permissions scopePermissions, Permission... permissions) { - return requirePermissions(ctx, scopePermissions, new Permissions(permissions)); + 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 requireAllPermissions(ctx, scopePermissions, new Permissions(permissions)); } private record ValidationResult(boolean authorized, String userId, Permissions scopePermissions) { 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 index 2157251..92bd6b5 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java @@ -28,7 +28,7 @@ public void handle( String userId, Permissions scopePermissions ) throws Exception { - if (!this.requirePermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; + if (this.requireAllPermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; UUID uuid = UUID.fromString(ctx.pathParam("uuid")); @@ -50,7 +50,7 @@ public void handle( Permissions permissions = this.getDatabaseAccess() .getProjectPermissions(userId, projectId) .unwrap(ctx); - if (!this.requirePermissions(ctx, permissions, Permission.MODIFY_API_KEY)) return; + if (this.requireAllPermissions(ctx, permissions, Permission.MODIFY_API_KEY)) return; } deleteApiKeyStatement.setBytes(1, UuidUtils.toBytes(uuid)); 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 index 6f5d60f..879dec3 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -33,7 +33,7 @@ public GenerateKeyEndpoint() { @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { - if (!this.requirePermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; + if (this.requireAllPermissions(ctx, scopePermissions, Permission.MODIFY_API_KEY)) return; Request request = this.decodeBody(ctx, Request.CODEC) .unwrap(ctx); @@ -82,7 +82,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss ResultSet resultSet = permissionStatement.executeQuery(); Permissions projectPermissions = new Permissions(resultSet.getLong("permissions")); requestedPermissions = requestedPermissions.restrict(projectPermissions.bits()); - if (!this.requirePermissions(ctx, projectPermissions, Permission.MODIFY_API_KEY)) return; + if (this.requireAllPermissions(ctx, projectPermissions, Permission.MODIFY_API_KEY)) return; } } case "user" -> requestedPermissions = requestedPermissions.restrict( 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 index 6e04e0a..06d9ae1 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -21,11 +21,8 @@ public DeleteProjectEndpoint() { @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { - ctx.status(403); - ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); - return; - } + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; String projectId = ctx.pathParam("project_id"); diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java index 2f70665..a93f5a2 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java @@ -23,11 +23,8 @@ public AddTeamMemberEndpoint() { @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { - ctx.status(403); - ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); - return; - } + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; String projectId = ctx.pathParam("project_id"); String memberUserId = ctx.pathParam("user_id"); diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java index 9612de5..a3c92f4 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java @@ -23,11 +23,8 @@ public RemoveTeamMemberEndpoint() { @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { - ctx.status(403); - ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); - return; - } + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; String projectId = ctx.pathParam("project_id"); String memberUserId = ctx.pathParam("user_id"); diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java index d6a4866..91576d5 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java @@ -23,11 +23,8 @@ public SetTeamMemberRoleEndpoint() { @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { - ctx.status(403); - ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); - return; - } + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; String projectId = ctx.pathParam("project_id"); String memberUserId = ctx.pathParam("user_id"); 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 index d7b2ae9..2576f0f 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java @@ -21,11 +21,8 @@ public DeleteSubmissionEndpoint() { @Override public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - if (!scopePermissions.hasPermissions(Permission.EDIT_PROJECT) && !scopePermissions.hasPermissions(Permission.MODERATE_PROJECTS)) { - ctx.status(403); - ctx.result("User lacks permission; required " + Permission.EDIT_PROJECT); - return; - } + if (this.requireAnyPermissions(ctx, scopePermissions, + Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; String submissionId = ctx.pathParam("submission_id"); From 6ad6a748fa260b8e43cf45bf2420173b2e159648 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 11:24:36 +1100 Subject: [PATCH 77/98] feat: Implement BunnyCDNUtils for later use, refactors to project metadata. --- .../net/modgarden/backend/data/NaturalId.java | 16 ++++ .../modgarden/backend/data/event/Project.java | 15 +--- .../backend/data/fixer/fix/V5ToV6.java | 2 - .../modgarden/backend/oauth/OAuthService.java | 7 +- .../oauth/client/BunnyCdnOAuthClient.java | 44 +++++++++++ .../oauth/client/DiscordOAuthClient.java | 5 ++ .../oauth/client/GithubOAuthClient.java | 5 ++ .../client/MinecraftServicesOAuthClient.java | 5 ++ .../oauth/client/ModrinthOAuthClient.java | 5 ++ .../backend/oauth/client/OAuthClient.java | 4 +- .../modgarden/backend/util/BunnyCdnUtils.java | 75 +++++++++++++++++++ .../modgarden/backend/util/MetadataUtils.java | 14 +--- .../modgarden/backend/util/ModrinthUtils.java | 16 +--- 13 files changed, 172 insertions(+), 41 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/oauth/client/BunnyCdnOAuthClient.java create mode 100644 src/main/java/net/modgarden/backend/util/BunnyCdnUtils.java diff --git a/src/main/java/net/modgarden/backend/data/NaturalId.java b/src/main/java/net/modgarden/backend/data/NaturalId.java index 30319d0..b87cbc7 100644 --- a/src/main/java/net/modgarden/backend/data/NaturalId.java +++ b/src/main/java/net/modgarden/backend/data/NaturalId.java @@ -1,8 +1,11 @@ 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; @@ -68,6 +71,19 @@ public static String generate(String table, String key, String key2, 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/event/Project.java b/src/main/java/net/modgarden/backend/data/event/Project.java index add653c..41b57a6 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -58,28 +58,21 @@ private static DataResult validate(String id) { return DataResult.error(() -> "Failed to get project with id '" + id + "'."); } - public record Metadata(String modId, String name, @Nullable String description, - String sourceUrl, String iconUrl, @Nullable String bannerUrl) { + public record Metadata(String modId, String name, @Nullable String description, String sourceUrl) { public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( Codec.STRING.fieldOf("mod_id").forGetter(Metadata::modId), Codec.STRING.fieldOf("name").forGetter(Metadata::name), Codec.STRING.optionalFieldOf("description").forGetter(Metadata::descriptionAsOptional), - Codec.STRING.fieldOf("source_url").forGetter(Metadata::sourceUrl), - Codec.STRING.fieldOf("icon_url").forGetter(Metadata::iconUrl), - Codec.STRING.optionalFieldOf("banner_url").forGetter(Metadata::bannerUrlAsOptional) + Codec.STRING.fieldOf("source_url").forGetter(Metadata::sourceUrl) ).apply(inst, Metadata::new)); @SuppressWarnings("OptionalUsedAsFieldOrParameterType") - private Metadata(String modId, String name, Optional description, String sourceUrl, String iconUrl, Optional bannerUrl) { - this(modId, name, description.orElse(null), sourceUrl, iconUrl, bannerUrl.orElse(null)); + private Metadata(String modId, String name, Optional description, String sourceUrl) { + this(modId, name, description.orElse(null), sourceUrl); } private Optional descriptionAsOptional() { return Optional.ofNullable(description); } - - private Optional bannerUrlAsOptional() { - return Optional.ofNullable(bannerUrl); - } } } 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 index 62aa775..ae8136b 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -423,8 +423,6 @@ INSERT INTO project_metadata (project_id, mod_id, name, description, source_url, projectMetadataInsertStatement.setString(3, modrinthData.name()); projectMetadataInsertStatement.setString(4, modrinthData.description()); projectMetadataInsertStatement.setString(5, modrinthData.sourceUrl()); - projectMetadataInsertStatement.setString(6, modrinthData.iconUrl()); - projectMetadataInsertStatement.setString(7, modrinthData.bannerUrl()); projectMetadataInsertStatement.executeUpdate(); } catch (Exception e) { throw new RuntimeException(e); 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/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/MetadataUtils.java b/src/main/java/net/modgarden/backend/util/MetadataUtils.java index 884f775..3a87e69 100644 --- a/src/main/java/net/modgarden/backend/util/MetadataUtils.java +++ b/src/main/java/net/modgarden/backend/util/MetadataUtils.java @@ -25,6 +25,7 @@ // 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. +// public class MetadataUtils { private static final String USER_AGENT = "ModGardenEvent/backend/" + Landing.getInstance().version() + " (modgarden.net)"; @@ -104,17 +105,12 @@ public static Project.Metadata getMetadataFromFabricModJson(@NotNull URI jarUri, String description = fmj.getAsJsonPrimitive("description").getAsString(); String sourceUrl = getFmjSourceUrl(fmj, externalData); - // TODO: Handle Icon and Banner Uploads to CDN. - String iconUrl = "placeholder"; - String bannerUrl = "placeholder"; metadata = new Project.Metadata( modId, name, description, - sourceUrl, - iconUrl, - bannerUrl + sourceUrl ); } @@ -152,9 +148,5 @@ private static String getFmjSourceUrl(JsonObject fmj, ExternalData data) { throw new NullPointerException("Could not find source URL from either fabric.mod.json or external data."); } - public record ExternalData(@Nullable String externalSourceUrl, - @Nullable String externalIconUrl, - @Nullable String externalBannerUrl) { - - } + 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 index c8d2259..c0ce270 100644 --- a/src/main/java/net/modgarden/backend/util/ModrinthUtils.java +++ b/src/main/java/net/modgarden/backend/util/ModrinthUtils.java @@ -52,10 +52,8 @@ public static MetadataUtils.ExternalData getModrinthExternalData(@NotNull Modrin JsonObject project = potentialProject.getAsJsonObject(); String sourceUrl = getSourceUrlFromModrinthProject(project); - String iconUrl = project.getAsJsonPrimitive("icon_url").getAsString(); - String bannerUrl = getBannerUrlFromModrinthProject(project); - return new MetadataUtils.ExternalData(sourceUrl, iconUrl, bannerUrl); + return new MetadataUtils.ExternalData(sourceUrl); } } @@ -69,16 +67,4 @@ public static String getSourceUrlFromModrinthProject(JsonObject project) { } return null; } - - public static String getBannerUrlFromModrinthProject(JsonObject project) { - if (project.has("gallery")) { - for (JsonElement galleryElement : project.getAsJsonArray("gallery")) { - JsonObject galleryObject = galleryElement.getAsJsonObject(); - if (galleryObject.getAsJsonPrimitive("featured").getAsBoolean()) { - return galleryObject.getAsJsonPrimitive("url").getAsString(); - } - } - } - return null; - } } From 6001c290cd53726b204705bc78e9189d4c6b7e78 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 18:30:03 +1100 Subject: [PATCH 78/98] fix: Use correct constructor for project endpoint. --- .../backend/endpoint/v2/project/GetProjectEndpoint.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 4ee0632..a4fe2f5 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -52,9 +52,7 @@ public static Project getProjectFromId(@NotNull Connection connection, projectMetadataResult.getString("mod_id"), projectMetadataResult.getString("name"), projectMetadataResult.getString("description"), - projectMetadataResult.getString("source_url"), - projectMetadataResult.getString("icon_url"), - projectMetadataResult.getString("banner_url") + projectMetadataResult.getString("source_url") ), team, permissions, From 0a02abb2dfd6ddd7afaeae4f53dd273660bc70f5 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 19:19:03 +1100 Subject: [PATCH 79/98] refactor: Refactors to member related endpoints, and add more permission checks. --- .../modgarden/backend/ModGardenBackend.java | 18 +++--- .../v2/AuthorizedProjectEndpoint.java | 28 +++++++++ .../AddMemberEndpoint.java} | 39 +++++------- .../RemoveMemberEndpoint.java} | 38 +++++++----- .../member/SetPermissionsEndpoint.java | 62 +++++++++++++++++++ .../SetRoleEndpoint.java} | 36 ++++++----- 6 files changed, 161 insertions(+), 60 deletions(-) rename src/main/java/net/modgarden/backend/endpoint/v2/project/{team/AddTeamMemberEndpoint.java => member/AddMemberEndpoint.java} (54%) rename src/main/java/net/modgarden/backend/endpoint/v2/project/{team/RemoveTeamMemberEndpoint.java => member/RemoveMemberEndpoint.java} (62%) create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetPermissionsEndpoint.java rename src/main/java/net/modgarden/backend/endpoint/v2/project/{team/SetTeamMemberRoleEndpoint.java => member/SetRoleEndpoint.java} (53%) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index b92de02..ee2c80b 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -32,12 +32,13 @@ 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.team.AddTeamMemberEndpoint; -import net.modgarden.backend.endpoint.v2.project.team.RemoveTeamMemberEndpoint; -import net.modgarden.backend.endpoint.v2.project.team.SetTeamMemberRoleEndpoint; +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.OrderCorrectedRecordCodec; +import net.modgarden.backend.util.OrderCorrectedCodec; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -129,10 +130,11 @@ public void v2() { delete(DeleteKeyEndpoint::new); get(ListKeysEndpoint::new); - put(AddTeamMemberEndpoint::new); - delete(RemoveTeamMemberEndpoint::new); - put(SetTeamMemberRoleEndpoint::new); + put(AddMemberEndpoint::new); + put(SetPermissionsEndpoint::new); + put(SetRoleEndpoint::new); delete(DeleteProjectEndpoint::new); + delete(RemoveMemberEndpoint::new); get(GetProjectByIdEndpoint::new); get(GetProjectByModIdEndpoint::new); @@ -420,7 +422,7 @@ private static void updateSchemaVersion() { } private static void registerCodec(Type type, Codec codec) { - CODEC_REGISTRY.put(type, new OrderCorrectedRecordCodec<>(codec)); + CODEC_REGISTRY.put(type, new OrderCorrectedCodec<>(codec)); } private static JsonMapper createDFUMapper() { diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java index a448c72..7876ace 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java @@ -1,12 +1,16 @@ 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, PermissionScope permissionScope, boolean hasBody) { @@ -15,4 +19,28 @@ public AuthorizedProjectEndpoint(String path, PermissionScope permissionScope, b @Override public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; + + protected static boolean canModifyUser(Connection connection, + String projectId, + String userIdToModify, + 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, userIdToModify); + 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)) + return false; + } + + return true; + } } diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/AddMemberEndpoint.java similarity index 54% rename from src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java rename to src/main/java/net/modgarden/backend/endpoint/v2/project/member/AddMemberEndpoint.java index a93f5a2..21bb4f4 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/AddTeamMemberEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/AddMemberEndpoint.java @@ -1,23 +1,23 @@ -package net.modgarden.backend.endpoint.v2.project.team; +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.PermissionScope; 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.PUT; @EndpointMethod(PUT) -@EndpointPath("/v2/project/{project_id}/team/{user_id}") -public class AddTeamMemberEndpoint extends AuthorizedProjectEndpoint { - public AddTeamMemberEndpoint() { - super("{project_id}/team/{user_id}", PermissionScope.ALL, true); +@EndpointPath("/v2/project/{project_id}/add_member") +public class AddMemberEndpoint extends AuthorizedProjectEndpoint { + public AddMemberEndpoint() { + super("{project_id}/add_member", PermissionScope.ALL, true); } @Override @@ -27,30 +27,25 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; String projectId = ctx.pathParam("project_id"); - String memberUserId = ctx.pathParam("user_id"); + Request request = decodeBody(ctx, Request.CODEC) + .unwrap(ctx); + + if (request == null) return; try ( var connection = this.getDatabaseConnection(); - var checkStatement = connection.prepareStatement(""" - SELECT 1 - FROM project_roles - WHERE project_id = ? AND user_id = ? - """); var insertStatement = connection.prepareStatement(""" - INSERT INTO project_roles (project_id, user_id) + INSERT OR IGNORE INTO project_roles (project_id, user_id) VALUES (?, ?) """) ) { - checkStatement.setString(1, projectId); - checkStatement.setString(2, memberUserId); - ResultSet checkResult = checkStatement.executeQuery(); - - // Check if the user is already a member of the project to avoid an exception being thrown. - if (checkResult.getBoolean(1)) return; - insertStatement.setString(1, projectId); - insertStatement.setString(2, memberUserId); + insertStatement.setString(2, request.userId()); insertStatement.executeUpdate(); } } + + 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/team/RemoveTeamMemberEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/RemoveMemberEndpoint.java similarity index 62% rename from src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java rename to src/main/java/net/modgarden/backend/endpoint/v2/project/member/RemoveMemberEndpoint.java index a3c92f4..c48e499 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/RemoveTeamMemberEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/RemoveMemberEndpoint.java @@ -1,9 +1,11 @@ -package net.modgarden.backend.endpoint.v2.project.team; +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.PermissionScope; 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; @@ -14,24 +16,26 @@ import static net.modgarden.backend.endpoint.EndpointMethod.Method.DELETE; @EndpointMethod(DELETE) -@EndpointPath("/v2/project/{project_id}/team/{user_id}") -public class RemoveTeamMemberEndpoint extends AuthorizedProjectEndpoint { - public RemoveTeamMemberEndpoint() { - super("{project_id}/team/{user_id}", PermissionScope.ALL, true); +@EndpointPath("/v2/project/{project_id}/remove_member") +public class RemoveMemberEndpoint extends AuthorizedProjectEndpoint { + public RemoveMemberEndpoint() { + super("{project_id}/remove_member", PermissionScope.ALL, true); } @Override public void handle(@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"); - String memberUserId = ctx.pathParam("user_id"); + 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 permissionCheckStatement = connection.prepareStatement(""" + var memberPermissionsStatement = connection.prepareStatement(""" SELECT permissions FROM project_roles WHERE project_id = ? AND user_id = ? @@ -46,12 +50,14 @@ SELECT COUNT(*) WHERE project_id = ? AND user_id = ? """) ) { - permissionCheckStatement.setString(1, projectId); - permissionCheckStatement.setString(2, memberUserId); - ResultSet memberPermissionsResult = permissionCheckStatement.executeQuery(); + memberPermissionsStatement.setString(1, projectId); + memberPermissionsStatement.setString(2, request.userId()); + ResultSet memberPermissionsResult = memberPermissionsStatement.executeQuery(); Permissions memberPermissions = new Permissions(memberPermissionsResult.getLong(1)); - // TODO: Figure out if team members that can edit project can remove project administrators... That feels unintended. + // If a non-administrator attempts to remove an administrator, return. + if (!canModifyUser(connection, projectId, request.userId(), scopePermissions)) return; + boolean memberCanEditProject = memberPermissions.hasPermissions(Permission.EDIT_PROJECT); // 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. @@ -62,8 +68,12 @@ SELECT COUNT(*) } deleteStatement.setString(1, projectId); - deleteStatement.setString(2, memberUserId); + deleteStatement.setString(2, request.userId()); deleteStatement.executeUpdate(); } } + + 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..0f88204 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetPermissionsEndpoint.java @@ -0,0 +1,62 @@ +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.PermissionScope; +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 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", PermissionScope.ALL, true); + } + + @Override + public void handle(@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 (!canModifyUser(connection, projectId, usersToPermissions.getKey(), scopePermissions)) return; + + updateStatement.setLong(1, usersToPermissions.getValue().bits()); + updateStatement.setString(2, projectId); + updateStatement.setString(3, usersToPermissions.getKey()); + updateStatement.executeUpdate(); + } + } + } + + 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/team/SetTeamMemberRoleEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetRoleEndpoint.java similarity index 53% rename from src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java rename to src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetRoleEndpoint.java index 91576d5..bbb6f8a 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/team/SetTeamMemberRoleEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/member/SetRoleEndpoint.java @@ -1,23 +1,25 @@ -package net.modgarden.backend.endpoint.v2.project.team; +package net.modgarden.backend.endpoint.v2.project.member; 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.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}/team/{user_id}/set_role") -public class SetTeamMemberRoleEndpoint extends AuthorizedProjectEndpoint { - public SetTeamMemberRoleEndpoint() { - super("{project_id}/team/{user_id}/set_role", PermissionScope.ALL, true); +@EndpointPath("/v2/project/{project_id}/set_role") +public class SetRoleEndpoint extends AuthorizedProjectEndpoint { + public SetRoleEndpoint() { + super("{project_id}/set_role", PermissionScope.ALL, true); } @Override @@ -27,7 +29,6 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; String projectId = ctx.pathParam("project_id"); - String memberUserId = ctx.pathParam("user_id"); Request request = decodeBody(ctx, Request.CODEC) .unwrap(ctx); @@ -36,22 +37,25 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss try ( var connection = this.getDatabaseConnection(); - var statement = connection.prepareStatement(""" + var updateStatement = connection.prepareStatement(""" UPDATE project_roles SET role_name = ? WHERE project_id = ? AND user_id = ? """) ) { - statement.setString(1, request.roleName()); - statement.setString(2, projectId); - statement.setString(3, memberUserId); - statement.executeUpdate(); + for (Map.Entry usersToRoleName : request.usersToRoleName().entrySet()) { + if (!canModifyUser(connection, projectId, usersToRoleName.getKey(), scopePermissions)) return; + + updateStatement.setString(1, usersToRoleName.getValue()); + updateStatement.setString(2, projectId); + updateStatement.setString(3, usersToRoleName.getKey()); + updateStatement.executeUpdate(); + } } } - public record Request(String roleName) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("role_name").forGetter(Request::roleName) - ).apply(inst, Request::new)); + public record Request(Map usersToRoleName) { + public static final Codec CODEC = Codec.unboundedMap(User.ID_CODEC, Codec.STRING) + .xmap(Request::new, Request::usersToRoleName); } } From 0240f8d96deaeb3b5a3c19998ebf350d94392b48 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 19:19:46 +1100 Subject: [PATCH 80/98] refactor: OrderCorrectedRecordCodec -> OrderCorrectedCodec. --- ...derCorrectedRecordCodec.java => OrderCorrectedCodec.java} | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) rename src/main/java/net/modgarden/backend/util/{OrderCorrectedRecordCodec.java => OrderCorrectedCodec.java} (95%) diff --git a/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java b/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java similarity index 95% rename from src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java rename to src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java index a9e38e6..6c83552 100644 --- a/src/main/java/net/modgarden/backend/util/OrderCorrectedRecordCodec.java +++ b/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java @@ -11,12 +11,11 @@ /// /// @see Mojang/DataFixerUpper#101 /// @param The type parameter of the RecordCodecBuilder. - @SuppressWarnings("ClassCanBeRecord") -public class OrderCorrectedRecordCodec implements Codec { +public class OrderCorrectedCodec implements Codec { private final Codec codec; - public OrderCorrectedRecordCodec(Codec codec) { + public OrderCorrectedCodec(Codec codec) { this.codec = codec; } From 3c77a8cb39a6ac8a29d8f91b2a3dab6f147a0852 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 19:24:39 +1100 Subject: [PATCH 81/98] refactor: Modify submission endpoint packages. --- src/main/java/net/modgarden/backend/ModGardenBackend.java | 2 +- .../backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java | 1 + .../v2/{event => submission}/GetSubmissionByIdEndpoint.java | 2 +- .../v2/{event => submission}/GetSubmissionEndpoint.java | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) rename src/main/java/net/modgarden/backend/endpoint/v2/{event => submission}/GetSubmissionByIdEndpoint.java (96%) rename src/main/java/net/modgarden/backend/endpoint/v2/{event => submission}/GetSubmissionEndpoint.java (97%) diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index ee2c80b..9efcb63 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -28,7 +28,7 @@ import net.modgarden.backend.endpoint.v2.auth.GenerateKeyEndpoint; import net.modgarden.backend.endpoint.v2.auth.ListKeysEndpoint; import net.modgarden.backend.endpoint.v2.project.DeleteProjectEndpoint; -import net.modgarden.backend.endpoint.v2.event.GetSubmissionByIdEndpoint; +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; 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 index 739888b..b8b92d2 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java @@ -4,6 +4,7 @@ import net.modgarden.backend.data.event.Submission; 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; diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java similarity index 96% rename from src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java rename to src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java index d9e0f24..84dc51a 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java @@ -1,4 +1,4 @@ -package net.modgarden.backend.endpoint.v2.event; +package net.modgarden.backend.endpoint.v2.submission; import io.javalin.http.Context; import net.modgarden.backend.data.event.Submission; diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java similarity index 97% rename from src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java rename to src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java index d680d23..020b40f 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java @@ -1,4 +1,4 @@ -package net.modgarden.backend.endpoint.v2.event; +package net.modgarden.backend.endpoint.v2.submission; import io.javalin.http.Context; import net.modgarden.backend.data.Platform; From d4e8362ccd7ecb27b4929433cf78dcc5bdfcadce Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 20:56:08 +1100 Subject: [PATCH 82/98] feat: Create project endpoint and project metadata rewrite. --- .../modgarden/backend/ModGardenBackend.java | 14 ++++- .../modgarden/backend/data/Integration.java | 3 +- .../net/modgarden/backend/data/Metadata.java | 16 +++++ .../modgarden/backend/data/event/Project.java | 62 +++++++----------- .../data/event/metadata/DraftMetadata.java | 17 +++++ .../data/event/metadata/ModMetadata.java | 32 ++++++++++ .../backend/data/fixer/fix/V5ToV6.java | 51 ++++++++------- .../event/GetSubmissionByModIdEndpoint.java | 22 +++---- .../v2/project/CreateProjectEndpoint.java | 63 +++++++++++++++++++ .../v2/project/GetProjectByModIdEndpoint.java | 8 ++- .../v2/project/GetProjectEndpoint.java | 62 +++++++++++++----- .../v2/submission/GetSubmissionEndpoint.java | 15 ++--- .../modgarden/backend/util/MetadataUtils.java | 43 +++++++------ 13 files changed, 284 insertions(+), 124 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/data/Metadata.java create mode 100644 src/main/java/net/modgarden/backend/data/event/metadata/DraftMetadata.java create mode 100644 src/main/java/net/modgarden/backend/data/event/metadata/ModMetadata.java create mode 100644 src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 9efcb63..6fd23bb 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -27,6 +27,7 @@ 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; @@ -130,6 +131,7 @@ public void v2() { delete(DeleteKeyEndpoint::new); get(ListKeysEndpoint::new); + post(CreateProjectEndpoint::new); put(AddMemberEndpoint::new); put(SetPermissionsEndpoint::new); put(SetRoleEndpoint::new); @@ -298,14 +300,20 @@ PRIMARY KEY (id) ) """); statement.addBatch(""" - CREATE TABLE IF NOT EXISTS project_metadata ( + 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, - icon_url TEXT NOT NULL, - banner_url TEXT, FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (project_id) ) diff --git a/src/main/java/net/modgarden/backend/data/Integration.java b/src/main/java/net/modgarden/backend/data/Integration.java index 1848d47..4cbbf01 100644 --- a/src/main/java/net/modgarden/backend/data/Integration.java +++ b/src/main/java/net/modgarden/backend/data/Integration.java @@ -6,10 +6,11 @@ public interface Integration { Codec getCodec(); + @SuppressWarnings("unchecked") static Codec fromCodec(Codec codec) { return codec.flatComapMap( t -> t, - _ -> DataResult.error(() -> "Cannot safely convert from a typed integration to a generic integration.") + integration -> DataResult.success((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..1b449b6 --- /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.DataResult; +import com.mojang.serialization.MapCodec; + +public interface Metadata { + MapCodec codec(); + + static MapCodec fromCodec(MapCodec codec) { + //noinspection unchecked + return codec.flatXmap( + DataResult::success, + metadata -> DataResult.success((T)metadata) // We can't encode unless an unsafe cast happens. + ); + } +} 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 41b57a6..e404a84 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -2,11 +2,13 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; +import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; import net.modgarden.backend.ModGardenBackend; +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 net.modgarden.backend.util.ExtraCodecs; -import org.jetbrains.annotations.Nullable; import java.sql.Connection; import java.sql.PreparedStatement; @@ -14,37 +16,39 @@ import java.sql.SQLException; import java.util.List; import java.util.Map; -import java.util.Optional; + +import static java.util.Map.entry; +import static net.modgarden.backend.data.Metadata.fromCodec; // TODO: Allow creating organisations, allow projects to be attributed to an organisation. public record Project(String id, Metadata metadata, Map team, Map permissions, - List submissions, - Map ext) { + List submissions) { + private static final Map> METADATA_MAP_CODECS = Map.ofEntries( + entry("draft", fromCodec(DraftMetadata.CODEC)), + entry("mod", fromCodec(ModMetadata.CODEC)) + ); + private static final Codec METADATA_CODEC = Codec.STRING.dispatch(metadata -> { + if (metadata instanceof DraftMetadata) { + return "draft"; + } else if (metadata instanceof ModMetadata) { + return "mod"; + } else { + throw new IllegalStateException("unregistered metadata type please do not let this ever happen"); + } + }, 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("type").forGetter(_ -> "mod"), // TODO: Unhardcode this from mod. - Metadata.CODEC.fieldOf("metadata").forGetter(Project::metadata), + 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), - ExtraCodecs.EXT_CODEC.fieldOf("ext").forGetter(Project::ext) + 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); - // TODO: Remove this as soon as 'type' is no longer hardcoded. - private Project(String id, - String _unused, - Metadata metadata, - Map team, - Map permissions, - List submissions, - Map ex) { - this(id, metadata, team, permissions, submissions, ex); - } - private static DataResult validate(String id) { try (Connection connection = ModGardenBackend.createDatabaseConnection(); PreparedStatement prepared = connection.prepareStatement("SELECT 1 FROM projects WHERE id = ?")) { @@ -57,22 +61,4 @@ private static DataResult validate(String id) { } return DataResult.error(() -> "Failed to get project with id '" + id + "'."); } - - public record Metadata(String modId, String name, @Nullable String description, String sourceUrl) { - public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( - Codec.STRING.fieldOf("mod_id").forGetter(Metadata::modId), - Codec.STRING.fieldOf("name").forGetter(Metadata::name), - Codec.STRING.optionalFieldOf("description").forGetter(Metadata::descriptionAsOptional), - Codec.STRING.fieldOf("source_url").forGetter(Metadata::sourceUrl) - ).apply(inst, Metadata::new)); - - @SuppressWarnings("OptionalUsedAsFieldOrParameterType") - private Metadata(String modId, String name, Optional description, String sourceUrl) { - this(modId, name, description.orElse(null), sourceUrl); - } - - private Optional descriptionAsOptional() { - return Optional.ofNullable(description); - } - } } 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..dab3efb --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/event/metadata/DraftMetadata.java @@ -0,0 +1,17 @@ +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 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..005c52e --- /dev/null +++ b/src/main/java/net/modgarden/backend/data/event/metadata/ModMetadata.java @@ -0,0 +1,32 @@ +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 MapCodec codec() { + return CODEC; + } +} 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 index ae8136b..df5bed4 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java +++ b/src/main/java/net/modgarden/backend/data/fixer/fix/V5ToV6.java @@ -1,5 +1,6 @@ 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; @@ -121,15 +122,22 @@ 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_metadata ( + 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, - icon_url TEXT NOT NULL, - banner_url TEXT, FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (project_id) ) @@ -148,13 +156,14 @@ 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(""" - INSERT INTO submissions (id, event, project_id, submitted) - SELECT id, event, project_id, submitted from submissions_old + UPDATE submissions_old + SET id = generate_natural_id('submissions', id, NULL, 5) """); statement.addBatch(""" - UPDATE submissions - SET id = generate_natural_id_from_snowflake_id('submissions', id) + 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. @@ -168,11 +177,6 @@ INSERT INTO submissions (id, event, project_id, submitted) ) """); - statement.addBatch(""" - UPDATE submissions_mr - SET id = generate_natural_id_from_snowflake_id('submissions_mr', id) - """); - statement.addBatch(""" CREATE TABLE IF NOT EXISTS submission_type_modrinth ( submission_id TEXT NOT NULL, @@ -351,7 +355,7 @@ INSERT INTO team_invites (code, project_id, user_id, expires, role) statement.addBatch(""" UPDATE users - SET id = generate_natural_id_from_snowflake_id('users', id) + SET id = generate_natural_id('users', id, NULL, 5) """); statement.addBatch(""" @@ -385,12 +389,12 @@ INSERT INTO users VALUES ('abcde', 'tiny_pineapple', unix_millis(), 0) statement.addBatch(""" UPDATE projects - SET id = generate_natural_id_from_snowflake_id('projects', id) + SET id = generate_natural_id('projects', id, NULL, 5) """); statement.addBatch(""" UPDATE events - SET id = generate_natural_id_from_snowflake_id('events', id) + SET id = generate_natural_id('events', id, NULL, 5) """); statement.addBatch(""" UPDATE events @@ -405,8 +409,8 @@ INSERT INTO users VALUES ('abcde', 'tiny_pineapple', unix_millis(), 0) INNER JOIN submissions s ON s.id = mr.submission_id """); var projectMetadataInsertStatement = connection.prepareStatement(""" - INSERT INTO project_metadata (project_id, mod_id, name, description, source_url, icon_url, banner_url) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO project_mod_metadata (project_id, mod_id, name, description, source_url) + VALUES (?, ?, ?, ?, ?) """); var modrinthSubmissionsResult = modrinthSubmissionsStatement.executeQuery(); @@ -417,12 +421,15 @@ INSERT INTO project_metadata (project_id, mod_id, name, description, source_url, String modrinthVersionId = modrinthSubmissionsResult.getString("version_id"); try { - var modrinthData = MetadataUtils.getMetadataFromModrinth(modrinthId, modrinthVersionId); + 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, modrinthData.modId()); - projectMetadataInsertStatement.setString(3, modrinthData.name()); - projectMetadataInsertStatement.setString(4, modrinthData.description()); - projectMetadataInsertStatement.setString(5, modrinthData.sourceUrl()); + 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); 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 index b8b92d2..912dd3a 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java @@ -32,9 +32,9 @@ public void handle(@NotNull Context ctx) throws Exception { FROM events WHERE event_type_slug = ? AND slug = ? """); - var projectMetadataStatement = connection.prepareStatement(""" + var projectModMetadataStatement = connection.prepareStatement(""" SELECT project_id - FROM project_metadata + FROM project_mod_metadata WHERE mod_id = ? """); var submissionsStatement = connection.prepareStatement(""" @@ -43,15 +43,6 @@ public void handle(@NotNull Context ctx) throws Exception { WHERE project_id = ? AND event = ? """) ) { - projectMetadataStatement.setString(1, modId); - var projectMetadataResult = projectMetadataStatement.executeQuery(); - - if (!projectMetadataResult.isBeforeFirst()) { - ctx.result("Could not find mod with id '" + modId + "'"); - ctx.status(404); - return; - } - eventStatement.setString(1, eventTypeSlug); eventStatement.setString(2, eventSlug); var eventResult = eventStatement.executeQuery(); @@ -62,6 +53,15 @@ public void handle(@NotNull Context ctx) throws Exception { 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"); 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..b4a03b5 --- /dev/null +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java @@ -0,0 +1,63 @@ +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 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 handle(@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); + } + } + + 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/GetProjectByModIdEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java index 62bc541..44c1ee1 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java @@ -25,18 +25,20 @@ public void handle(@NotNull Context ctx) throws Exception { var connection = this.getDatabaseConnection(); var projectMetadataStatement = connection.prepareStatement(""" SELECT project_id - FROM project_metadata + FROM project_mod_metadata WHERE mod_id = ? """) ) { projectMetadataStatement.setString(1, modId); ResultSet projectResult = projectMetadataStatement.executeQuery(); - if (!projectResult.isBeforeFirst()) { + String projectId = projectResult.getString("project_id"); + + if (projectId == null) { ctx.result("Could not find project from mod id '" + modId + "'"); ctx.status(404); return; } - String projectId = projectResult.getString("project_id"); + Project project = GetProjectEndpoint.getProjectFromId(connection, projectId); ctx.json(project); 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 index a4fe2f5..83953aa 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -1,7 +1,10 @@ package net.modgarden.backend.endpoint.v2.project; import io.javalin.http.Context; +import net.modgarden.backend.data.Metadata; import net.modgarden.backend.data.event.Project; +import net.modgarden.backend.data.event.metadata.DraftMetadata; +import net.modgarden.backend.data.event.metadata.ModMetadata; import net.modgarden.backend.endpoint.Endpoint; import net.modgarden.backend.endpoint.EndpointPath; import org.jetbrains.annotations.NotNull; @@ -19,18 +22,56 @@ public GetProjectEndpoint(String path) { @Override public abstract void handle(@NotNull Context ctx) throws Exception; + // TODO: Require view project permissions or being a member of the project to view draft projects. public static Project getProjectFromId(@NotNull Connection connection, @NotNull String projectId) throws Exception { 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 projectMetadataStatement = connection.prepareStatement("SELECT mod_id, name, description, source_url, icon_url, banner_url FROM project_metadata WHERE project_id = ?"); - var submissionsStatement = connection.prepareStatement("SELECT id FROM submissions WHERE project_id = ?") + 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 = ? + """) ) { - projectMetadataStatement.setString(1, projectId); - ResultSet projectMetadataResult = projectMetadataStatement.executeQuery(); + 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 NullPointerException("Could not find metadata for project '" + projectId + "'"); + } + projectRolesStatement.setString(1, projectId); ResultSet projectRolesResult = projectRolesStatement.executeQuery(); @@ -48,17 +89,10 @@ public static Project getProjectFromId(@NotNull Connection connection, return new Project( projectId, - new Project.Metadata( - projectMetadataResult.getString("mod_id"), - projectMetadataResult.getString("name"), - projectMetadataResult.getString("description"), - projectMetadataResult.getString("source_url") - ), + metadata, team, permissions, - submissions, - // TODO: Add ext field to the database. - Collections.emptyMap() + submissions ); } } 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 index 020b40f..5e3cb23 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java @@ -24,15 +24,10 @@ public static Submission getSubmissionFromId(@NotNull Connection connection, @NotNull String submissionId) throws Exception { try ( var submissionStatement = connection.prepareStatement(""" - SELECT event, submitted + SELECT event, project_id, submitted FROM submissions WHERE id = ? """); - var projectStatement = connection.prepareStatement(""" - SELECT id - FROM projects - WHERE id = ? - """); var modrinthSubmissionTypeStatement = connection.prepareStatement(""" SELECT modrinth_id, version_id FROM submission_type_modrinth @@ -41,9 +36,9 @@ public static Submission getSubmissionFromId(@NotNull Connection connection, ) { submissionStatement.setString(1, submissionId); ResultSet submissionResult = submissionStatement.executeQuery(); - - projectStatement.setString(1, submissionId); - ResultSet projectResult = projectStatement.executeQuery(); + if (!submissionResult.isBeforeFirst()) { + throw new NullPointerException("Could not find submission '" + submissionId + "'"); + } modrinthSubmissionTypeStatement.setString(1, submissionId); ResultSet modrinthSubmissionTypeResult = modrinthSubmissionTypeStatement.executeQuery(); @@ -65,7 +60,7 @@ public static Submission getSubmissionFromId(@NotNull Connection connection, submissionId, submissionResult.getString("event"), submissionResult.getLong("submitted"), - GetProjectEndpoint.getProjectFromId(connection, projectResult.getString("id")), + GetProjectEndpoint.getProjectFromId(connection, submissionResult.getString("project_id")), platform ); } diff --git a/src/main/java/net/modgarden/backend/util/MetadataUtils.java b/src/main/java/net/modgarden/backend/util/MetadataUtils.java index 3a87e69..ff83059 100644 --- a/src/main/java/net/modgarden/backend/util/MetadataUtils.java +++ b/src/main/java/net/modgarden/backend/util/MetadataUtils.java @@ -5,7 +5,8 @@ import com.google.gson.JsonParser; import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.Landing; -import net.modgarden.backend.data.event.Project; +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; @@ -18,19 +19,18 @@ import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; 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. -// +/// 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 Project.Metadata getMetadataFromModrinth(String modrinthProjectId, - String modrinthVersionId) throws Exception { + public static Metadata getMetadataFromModrinth(String modrinthProjectId, + String modrinthVersionId) throws Exception { ModrinthOAuthClient authClient = OAuthService.MODRINTH.authenticate(); ExternalData externalData = ModrinthUtils.getModrinthExternalData(authClient, modrinthProjectId); @@ -49,35 +49,34 @@ public static Project.Metadata getMetadataFromModrinth(String modrinthProjectId, throw new IllegalStateException("Attempted to get a non-JSON Object Modrinth Version whilst getting project metadata."); } JsonObject version = potentialVersion.getAsJsonObject(); - URI jarUri = null; + URI primaryUri = null; for (JsonElement potentialFile : version.getAsJsonArray("files")) { if (potentialFile.isJsonObject()) { JsonObject file = potentialFile.getAsJsonObject(); if (file.getAsJsonPrimitive("primary").getAsBoolean()) { - jarUri = URI.create(file.getAsJsonPrimitive("url").getAsString()); + primaryUri = URI.create(file.getAsJsonPrimitive("url").getAsString()); break; } } } - if (jarUri == null) { - throw new IllegalStateException("Could not find valid primary version URL from Modrinth version whilst getting project metadata."); + if (primaryUri == null) { + throw new IllegalStateException("Could not find valid primary download URL from Modrinth version whilst getting project metadata."); } - List loaders = new ArrayList<>(); for (JsonElement element : version.getAsJsonArray("loaders")) { - loaders.add(element.getAsJsonPrimitive().getAsString()); + String loader = element.getAsJsonPrimitive().getAsString(); + if (loader.equals("fabric")) { + return getMetadataFromFabricModJson(primaryUri, externalData); + } } - if (loaders.contains("fabric")) { - return getMetadataFromFabricModJson(jarUri, externalData); - } - throw new UnsupportedOperationException("All modloaders associated with the specified version are not implemented."); + throw new UnsupportedOperationException("All mod-loaders associated with the specified version are not implemented."); } } - public static Project.Metadata getMetadataFromFabricModJson(@NotNull URI jarUri, - @NotNull ExternalData externalData) throws Exception { + public static Metadata getMetadataFromFabricModJson(@NotNull URI jarUri, + @NotNull ExternalData externalData) throws Exception { var request = HttpRequest.newBuilder() .header("User-Agent", USER_AGENT) .uri(jarUri) @@ -87,7 +86,7 @@ public static Project.Metadata getMetadataFromFabricModJson(@NotNull URI jarUri, HttpResponse response = ModGardenBackend.HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofFile(temporaryFolder)); Path temporaryFilePath = response.body(); - Project.Metadata metadata; + Metadata metadata; try ( JarFile jarFile = new JarFile(temporaryFilePath.toFile()); InputStream fmjStream = getFmjAsStream(jarFile); @@ -106,7 +105,7 @@ public static Project.Metadata getMetadataFromFabricModJson(@NotNull URI jarUri, String sourceUrl = getFmjSourceUrl(fmj, externalData); - metadata = new Project.Metadata( + metadata = new ModMetadata( modId, name, description, From 399bfeed95187a619da729968f02dfd015fe891d Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 20:56:45 +1100 Subject: [PATCH 83/98] feat: Add debug comment to show the time that the data-fixer takes. --- .../java/net/modgarden/backend/data/fixer/DatabaseFixer.java | 3 +++ 1 file changed, 3 insertions(+) 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 875242e..4becf4a 100644 --- a/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java +++ b/src/main/java/net/modgarden/backend/data/fixer/DatabaseFixer.java @@ -34,6 +34,7 @@ public static int getSchemaVersion() { } public static void fixDatabase() { + long startTime = System.currentTimeMillis(); int version = -1; try (Connection connection = ModGardenBackend.createDatabaseConnection(); PreparedStatement schemaVersion = connection.prepareStatement("SELECT version FROM schema")) { @@ -67,5 +68,7 @@ public static void fixDatabase() { ModGardenBackend.LOG.error("Failed to fix data: ", ex); } } + long endTime = System.currentTimeMillis(); + ModGardenBackend.LOG.debug("Data-fixer took {}ms", endTime - startTime); } } From 48599283b99f3fd921830c5f588eae2e3502a628 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 20:57:47 +1100 Subject: [PATCH 84/98] feat: Make fields named 'type' apepar first within OrderCorrectedCodec. --- .../backend/util/OrderCorrectedCodec.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java b/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java index 6c83552..d58b587 100644 --- a/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java +++ b/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java @@ -3,7 +3,10 @@ import com.mojang.datafixers.util.Pair; import com.mojang.serialization.*; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; /// Accounts for a DFU bug where RecordCodecBuilder swaps the half-point at which members are encoded. /// @@ -42,9 +45,20 @@ public DataResult encode(E input, DynamicOps ops, T prefix) { } private static RecordBuilder correctEncoding(DynamicOps ops, RecordBuilder builder, MapLike newValues) { - if (newValues.entries().count() > 4) { - List> elements = newValues.entries().toList(); + List> elements = newValues.entries() + .collect(Collectors.toCollection(ArrayList::new)); + // TODO: Un-hardcode from 'type' and try to detect where dispatch codecs are. This will work for now. + Optional> dispatchKey = elements.stream().filter(pair -> ops.getStringValue(pair.getFirst()) + .resultOrPartial() + .orElse("null") + .equals("type") + ).findAny(); + dispatchKey.ifPresent(pair -> { + builder.add(pair.getFirst(), pair.getSecond()); + elements.remove(pair); + }); + if (elements.size() > 3) { for (int secondHalfIndex = (int)Math.ceil(elements.size() / 2.0F); secondHalfIndex < elements.size(); ++secondHalfIndex) { T key = elements.get(secondHalfIndex).getFirst(); T value = potentiallyCorrectElement(ops, elements.get(secondHalfIndex).getSecond()); From 48436e8168acd6cb82e025ae7d921ee24a346a98 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 21:02:48 +1100 Subject: [PATCH 85/98] refactor: Remove 'slug' field from Modrinth submission platform. --- .../backend/data/event/platform/ModrinthPlatform.java | 8 +++----- .../endpoint/v2/submission/GetSubmissionEndpoint.java | 6 ++---- 2 files changed, 5 insertions(+), 9 deletions(-) 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 index 7702925..9f7ca3d 100644 --- a/src/main/java/net/modgarden/backend/data/event/platform/ModrinthPlatform.java +++ b/src/main/java/net/modgarden/backend/data/event/platform/ModrinthPlatform.java @@ -10,21 +10,19 @@ /// An example based on Variant Lib would be as follows. /// ```json /// { +/// "type": "modrinth", /// "project_id": "LQCrGzOR", /// "version_id": "Qt7I0urr" -/// "slug": "variant-lib" /// } /// ``` /// /// @param projectId The project ID of the Modrinth project. /// @param versionId The version ID to pull from Modrinth for the mod JAR. -/// @param slug The slug of the Modrinth project. /// -public record ModrinthPlatform(String projectId, String versionId, String slug) implements Platform { +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), - Codec.STRING.fieldOf("slug").forGetter(ModrinthPlatform::slug) + Codec.STRING.fieldOf("version_id").forGetter(ModrinthPlatform::versionId) ).apply(inst, ModrinthPlatform::new)); @Override 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 index 5e3cb23..a5256f2 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java @@ -46,11 +46,9 @@ public static Submission getSubmissionFromId(@NotNull Connection connection, Platform platform; // TODO: Implement download URL submission type. if (modrinthSubmissionTypeResult.isBeforeFirst()) { - String modrinthId = modrinthSubmissionTypeResult.getString("modrinth_id"); platform = new ModrinthPlatform( - modrinthId, - modrinthSubmissionTypeResult.getString("version_id"), - ModrinthUtils.getSlugFromId(modrinthId) + modrinthSubmissionTypeResult.getString("modrinth_id"), + modrinthSubmissionTypeResult.getString("version_id") ); } else { throw new RuntimeException("Submission does not have a valid 'platform'"); From 9b7a0f8fba34cdcb1eef778f2e577104cb521d44 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 21:03:46 +1100 Subject: [PATCH 86/98] refactor: Remove getSlugFromId from ModrinthUtils. --- .../modgarden/backend/util/ModrinthUtils.java | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/src/main/java/net/modgarden/backend/util/ModrinthUtils.java b/src/main/java/net/modgarden/backend/util/ModrinthUtils.java index c0ce270..c8b6d6b 100644 --- a/src/main/java/net/modgarden/backend/util/ModrinthUtils.java +++ b/src/main/java/net/modgarden/backend/util/ModrinthUtils.java @@ -3,7 +3,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import net.modgarden.backend.oauth.OAuthService; import net.modgarden.backend.oauth.client.ModrinthOAuthClient; import org.jetbrains.annotations.NotNull; @@ -12,27 +11,6 @@ import java.net.http.HttpResponse; public class ModrinthUtils { - public static String getSlugFromId(String modrinthProjectId) throws Exception { - ModrinthOAuthClient authClient = OAuthService.MODRINTH.authenticate(); - 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 slug from ID."); - } - JsonObject project = potentialProject.getAsJsonObject(); - return project.getAsJsonPrimitive("slug").getAsString(); - } - } - public static MetadataUtils.ExternalData getModrinthExternalData(@NotNull ModrinthOAuthClient authClient, @NotNull String modrinthProjectId) throws Exception { HttpResponse projectResponse = authClient From ed812c4677fcf15920e41bdf511ac57ea06035a5 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 21:08:34 +1100 Subject: [PATCH 87/98] fix: Make AuthorizedEndpoint#requireAnyPermissions use the correct internal method. --- .../java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 87f4dc9..86d2eaf 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -290,7 +290,7 @@ protected boolean requireAllPermissions(Context ctx, Permissions scopePermission } protected boolean requireAnyPermissions(Context ctx, Permissions scopePermissions, Permission... permissions) { - return requireAllPermissions(ctx, scopePermissions, new Permissions(permissions)); + return requireAnyPermissions(ctx, scopePermissions, new Permissions(permissions)); } private record ValidationResult(boolean authorized, String userId, Permissions scopePermissions) { From 2bb556bd802c79d9611e8ac10a2314c493d36c81 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 21:18:46 +1100 Subject: [PATCH 88/98] refactor: Miscellaneous rewrites to integrations, metadata and platforms. --- .../net/modgarden/backend/data/Integration.java | 6 +++--- .../net/modgarden/backend/data/Metadata.java | 10 +++++----- .../net/modgarden/backend/data/Platform.java | 8 ++++++++ .../modgarden/backend/data/event/Project.java | 16 ++++------------ .../modgarden/backend/data/event/Submission.java | 14 ++++++-------- .../data/event/metadata/DraftMetadata.java | 5 +++++ .../backend/data/event/metadata/ModMetadata.java | 5 +++++ 7 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/Integration.java b/src/main/java/net/modgarden/backend/data/Integration.java index 4cbbf01..950375d 100644 --- a/src/main/java/net/modgarden/backend/data/Integration.java +++ b/src/main/java/net/modgarden/backend/data/Integration.java @@ -1,16 +1,16 @@ package net.modgarden.backend.data; import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; public interface Integration { Codec getCodec(); @SuppressWarnings("unchecked") static Codec fromCodec(Codec codec) { - return codec.flatComapMap( + //noinspection unchecked + return codec.xmap( t -> t, - integration -> DataResult.success((T)integration) // We can't encode unless an unsafe cast happens. + 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 index 1b449b6..a94ed92 100644 --- a/src/main/java/net/modgarden/backend/data/Metadata.java +++ b/src/main/java/net/modgarden/backend/data/Metadata.java @@ -1,16 +1,16 @@ package net.modgarden.backend.data; -import com.mojang.serialization.DataResult; import com.mojang.serialization.MapCodec; public interface Metadata { + String getName(); MapCodec codec(); - static MapCodec fromCodec(MapCodec codec) { + static MapCodec fromMapCodec(MapCodec codec) { //noinspection unchecked - return codec.flatXmap( - DataResult::success, - metadata -> DataResult.success((T)metadata) // We can't encode unless an unsafe cast happens. + 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/Platform.java b/src/main/java/net/modgarden/backend/data/Platform.java index 68ccbde..5d2db3a 100644 --- a/src/main/java/net/modgarden/backend/data/Platform.java +++ b/src/main/java/net/modgarden/backend/data/Platform.java @@ -5,4 +5,12 @@ 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/event/Project.java b/src/main/java/net/modgarden/backend/data/event/Project.java index e404a84..dab3073 100644 --- a/src/main/java/net/modgarden/backend/data/event/Project.java +++ b/src/main/java/net/modgarden/backend/data/event/Project.java @@ -18,7 +18,7 @@ import java.util.Map; import static java.util.Map.entry; -import static net.modgarden.backend.data.Metadata.fromCodec; +import static net.modgarden.backend.data.Metadata.fromMapCodec; // TODO: Allow creating organisations, allow projects to be attributed to an organisation. public record Project(String id, @@ -27,18 +27,10 @@ public record Project(String id, Map permissions, List submissions) { private static final Map> METADATA_MAP_CODECS = Map.ofEntries( - entry("draft", fromCodec(DraftMetadata.CODEC)), - entry("mod", fromCodec(ModMetadata.CODEC)) + entry("draft", fromMapCodec(DraftMetadata.CODEC)), + entry("mod", fromMapCodec(ModMetadata.CODEC)) ); - private static final Codec METADATA_CODEC = Codec.STRING.dispatch(metadata -> { - if (metadata instanceof DraftMetadata) { - return "draft"; - } else if (metadata instanceof ModMetadata) { - return "mod"; - } else { - throw new IllegalStateException("unregistered metadata type please do not let this ever happen"); - } - }, METADATA_MAP_CODECS::get); + 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), 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 d944e9b..4ab1657 100644 --- a/src/main/java/net/modgarden/backend/data/event/Submission.java +++ b/src/main/java/net/modgarden/backend/data/event/Submission.java @@ -16,27 +16,25 @@ import java.util.Map; import static java.util.Map.entry; +import static net.modgarden.backend.data.Platform.fromMapCodec; public record Submission(String id, String event, long timeSubmitted, Project project, Platform platform) { - private static final Map> PLATFORM_CODECS = Map.ofEntries( - entry("modrinth", mapPlatformCodec(ModrinthPlatform.CODEC)), - entry("download_url", mapPlatformCodec(DownloadUrlPlatform.CODEC)) + private static final Map> PLATFORM_MAP_CODECS = Map.ofEntries( + entry("modrinth", fromMapCodec(ModrinthPlatform.CODEC)), + entry("download_url", fromMapCodec(DownloadUrlPlatform.CODEC)) ); - @SuppressWarnings("unchecked") - private static

MapCodec mapPlatformCodec(MapCodec

platformCodec) { - return platformCodec.xmap(p -> p, p -> (P)p); - } + 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.dispatch(Platform::getName, PLATFORM_CODECS::get).fieldOf("platform").forGetter(Submission::platform) + PLATFORM_CODEC.fieldOf("platform").forGetter(Submission::platform) ).apply(inst, Submission::new)); public static final Codec ID_CODEC = Codec.STRING.validate(Submission::validate); 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 index dab3efb..c2800df 100644 --- a/src/main/java/net/modgarden/backend/data/event/metadata/DraftMetadata.java +++ b/src/main/java/net/modgarden/backend/data/event/metadata/DraftMetadata.java @@ -10,6 +10,11 @@ public record DraftMetadata(String name) implements Metadata { 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 index 005c52e..10ff565 100644 --- a/src/main/java/net/modgarden/backend/data/event/metadata/ModMetadata.java +++ b/src/main/java/net/modgarden/backend/data/event/metadata/ModMetadata.java @@ -25,6 +25,11 @@ private Optional descriptionAsOptional() { return Optional.ofNullable(description); } + @Override + public String getName() { + return "mod"; + } + @Override public MapCodec codec() { return CODEC; From ec25c88c6523fda36386a924d8675b012fff51c8 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sat, 22 Nov 2025 21:19:33 +1100 Subject: [PATCH 89/98] refactor: Rewrite unregistered scope exception message a bit. --- .../modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 879dec3..8011e4b 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -141,7 +141,7 @@ public record Request( } 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"); + throw new IllegalStateException("Unregistered scope type. Please do not let this ever happen."); } }, request -> { From a9200bbe8605d27ba85c21e15de5ae7159ff4235 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 17:35:58 -0500 Subject: [PATCH 90/98] fix: set status to 403 when non-administrator attempts to edit an administrator's permissions --- .../endpoint/v2/AuthorizedProjectEndpoint.java | 16 +++++++++++----- .../v2/project/member/RemoveMemberEndpoint.java | 2 +- .../project/member/SetPermissionsEndpoint.java | 2 +- .../v2/project/member/SetRoleEndpoint.java | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java index 7876ace..c3d5a38 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java @@ -20,10 +20,13 @@ public AuthorizedProjectEndpoint(String path, PermissionScope permissionScope, b @Override public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; - protected static boolean canModifyUser(Connection connection, - String projectId, - String userIdToModify, - Permissions selfPermissions) throws Exception { + protected static boolean canModifyUser( + Context ctx, + Connection connection, + String projectId, + String userIdToModify, + Permissions selfPermissions + ) throws Exception { try ( var memberPermissionsStatement = connection.prepareStatement(""" SELECT permissions @@ -37,8 +40,11 @@ protected static boolean canModifyUser(Connection connection, 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)) + if (memberPermissions.hasPermissions(Permission.ADMINISTRATOR) && !selfPermissions.hasPermissions(Permission.ADMINISTRATOR)) { + ctx.status(403); + ctx.result("Non-administrators may not edit administrators' permissions on projects"); return false; + } } return true; 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 index c48e499..5da6316 100644 --- 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 @@ -56,7 +56,7 @@ SELECT COUNT(*) Permissions memberPermissions = new Permissions(memberPermissionsResult.getLong(1)); // If a non-administrator attempts to remove an administrator, return. - if (!canModifyUser(connection, projectId, request.userId(), scopePermissions)) return; + if (!canModifyUser(ctx, connection, projectId, request.userId(), scopePermissions)) return; boolean memberCanEditProject = memberPermissions.hasPermissions(Permission.EDIT_PROJECT); 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 index 0f88204..f379346 100644 --- 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 @@ -45,7 +45,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss """) ) { for (Map.Entry usersToPermissions : request.usersToPermissions().entrySet()) { - if (!canModifyUser(connection, projectId, usersToPermissions.getKey(), scopePermissions)) return; + if (!canModifyUser(ctx, connection, projectId, usersToPermissions.getKey(), scopePermissions)) return; updateStatement.setLong(1, usersToPermissions.getValue().bits()); updateStatement.setString(2, projectId); 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 index bbb6f8a..35dcd82 100644 --- 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 @@ -44,7 +44,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss """) ) { for (Map.Entry usersToRoleName : request.usersToRoleName().entrySet()) { - if (!canModifyUser(connection, projectId, usersToRoleName.getKey(), scopePermissions)) return; + if (!canModifyUser(ctx, connection, projectId, usersToRoleName.getKey(), scopePermissions)) return; updateStatement.setString(1, usersToRoleName.getValue()); updateStatement.setString(2, projectId); From c52b7f2109b05aefbcea827601567bec261dbffa Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Sun, 23 Nov 2025 09:52:26 +1100 Subject: [PATCH 91/98] feat: Expand on OrderCorrectedCodec and rename it to ReadableOrderCodec. --- .../modgarden/backend/ModGardenBackend.java | 4 +- .../backend/util/OrderCorrectedCodec.java | 92 ---- .../backend/util/ReadableOrderCodec.java | 435 ++++++++++++++++++ 3 files changed, 437 insertions(+), 94 deletions(-) delete mode 100644 src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java create mode 100644 src/main/java/net/modgarden/backend/util/ReadableOrderCodec.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 6fd23bb..b255f92 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -39,7 +39,7 @@ 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.OrderCorrectedCodec; +import net.modgarden.backend.util.ReadableOrderCodec; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -430,7 +430,7 @@ private static void updateSchemaVersion() { } private static void registerCodec(Type type, Codec codec) { - CODEC_REGISTRY.put(type, new OrderCorrectedCodec<>(codec)); + CODEC_REGISTRY.put(type, new ReadableOrderCodec<>(codec)); } private static JsonMapper createDFUMapper() { diff --git a/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java b/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java deleted file mode 100644 index d58b587..0000000 --- a/src/main/java/net/modgarden/backend/util/OrderCorrectedCodec.java +++ /dev/null @@ -1,92 +0,0 @@ -package net.modgarden.backend.util; - -import com.mojang.datafixers.util.Pair; -import com.mojang.serialization.*; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -/// Accounts for a DFU bug where RecordCodecBuilder swaps the half-point at which members are encoded. -/// -/// This should only ever modify map encoding, which is where this bug is present. -/// -/// @see Mojang/DataFixerUpper#101 -/// @param The type parameter of the RecordCodecBuilder. -@SuppressWarnings("ClassCanBeRecord") -public class OrderCorrectedCodec implements Codec { - private final Codec codec; - - public OrderCorrectedCodec(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 -> { - DataResult> mapLike = ops.getMap(value); - if (!mapLike.hasResultOrPartial()) { - return value; - } - return correctEncoding( - ops, - ops.mapBuilder(), - mapLike.getOrThrow() - ).build(ops.empty()) - .resultOrPartial() - .orElse(value); - }); - } - - private static RecordBuilder correctEncoding(DynamicOps ops, RecordBuilder builder, MapLike newValues) { - List> elements = newValues.entries() - .collect(Collectors.toCollection(ArrayList::new)); - // TODO: Un-hardcode from 'type' and try to detect where dispatch codecs are. This will work for now. - Optional> dispatchKey = elements.stream().filter(pair -> ops.getStringValue(pair.getFirst()) - .resultOrPartial() - .orElse("null") - .equals("type") - ).findAny(); - dispatchKey.ifPresent(pair -> { - builder.add(pair.getFirst(), pair.getSecond()); - elements.remove(pair); - }); - - if (elements.size() > 3) { - for (int secondHalfIndex = (int)Math.ceil(elements.size() / 2.0F); secondHalfIndex < elements.size(); ++secondHalfIndex) { - T key = elements.get(secondHalfIndex).getFirst(); - T value = potentiallyCorrectElement(ops, elements.get(secondHalfIndex).getSecond()); - builder.add(key, value); - } - for (int firstHalfIndex = 0; firstHalfIndex < Math.ceil(elements.size() / 2.0F); ++firstHalfIndex) { - T key = elements.get(firstHalfIndex).getFirst(); - T value = potentiallyCorrectElement(ops, elements.get(firstHalfIndex).getSecond()); - builder.add(key, value); - } - } else { - for (Pair entry : newValues.entries().toList()) { - T key = entry.getFirst(); - T value = potentiallyCorrectElement(ops, entry.getSecond()); - builder.add(key, value); - } - } - - return builder; - } - - private static T potentiallyCorrectElement(DynamicOps ops, T element) { - var mapResult = ops.getMap(element).resultOrPartial(); - if (mapResult.isPresent()) { - return correctEncoding(ops, ops.mapBuilder(), mapResult.get()).build(ops.empty()) - .resultOrPartial() - .orElse(element); - } - return element; - } -} 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."); + } +} From d366c309d60516abc028e1cbef13141ac1ebe1cb Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 18:01:00 -0500 Subject: [PATCH 92/98] fix: make sure there's one admin left in a project --- .../v2/project/member/RemoveMemberEndpoint.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 index 5da6316..dc72474 100644 --- 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 @@ -43,7 +43,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss var permissionCountStatement = connection.prepareStatement(""" SELECT COUNT(*) FROM project_roles - WHERE project_id = ? AND has_permissions(permissions, 256) + WHERE project_id = ? AND has_permissions(permissions, 1) """); var deleteStatement = connection.prepareStatement(""" DELETE FROM project_roles @@ -58,13 +58,18 @@ SELECT COUNT(*) // If a non-administrator attempts to remove an administrator, return. if (!canModifyUser(ctx, connection, projectId, request.userId(), scopePermissions)) return; - boolean memberCanEditProject = memberPermissions.hasPermissions(Permission.EDIT_PROJECT); + 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. - if (memberCanEditProject) { + // 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) return; + if (permissionCountResult.getInt(1) < 2) { + ctx.status(400); + ctx.result("A project must have at least one administrator"); + return; + } } deleteStatement.setString(1, projectId); From 856d36621ca5f3702cf29596d1839278054aaaa2 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 18:04:04 -0500 Subject: [PATCH 93/98] fix: make `MODERATE_PROJECTS(0x10)` permission `ALL` scope --- src/main/java/net/modgarden/backend/data/Permission.java | 2 +- .../backend/endpoint/v2/AuthorizedProjectEndpoint.java | 4 ++-- .../backend/endpoint/v2/project/CreateProjectEndpoint.java | 3 +-- .../backend/endpoint/v2/project/DeleteProjectEndpoint.java | 3 +-- .../backend/endpoint/v2/project/member/AddMemberEndpoint.java | 3 +-- .../endpoint/v2/project/member/RemoveMemberEndpoint.java | 3 +-- .../endpoint/v2/project/member/SetPermissionsEndpoint.java | 4 +--- .../backend/endpoint/v2/project/member/SetRoleEndpoint.java | 3 +-- 8 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index afd32c7..6ccc4b4 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -20,7 +20,7 @@ public enum Permission { /// Edit this project. EDIT_PROJECT(0x8, "edit_project", PROJECT), /// Edit others' projects and hide them. - MODERATE_PROJECTS(0x10, "moderate_projects", USER), + 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. diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java index c3d5a38..9a6ecf2 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java @@ -13,8 +13,8 @@ @EndpointPath("/v2/project") public abstract class AuthorizedProjectEndpoint extends AuthorizedEndpoint { - public AuthorizedProjectEndpoint(String path, PermissionScope permissionScope, boolean hasBody) { - super(2, "project/" + path, permissionScope, hasBody); + public AuthorizedProjectEndpoint(String path, boolean hasBody) { + super(2, "project/" + path, PermissionScope.PROJECT, hasBody); } @Override 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 index b4a03b5..885a39b 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java @@ -3,7 +3,6 @@ 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; @@ -16,7 +15,7 @@ @EndpointPath("/v2/project/create") public class CreateProjectEndpoint extends AuthorizedProjectEndpoint { public CreateProjectEndpoint() { - super("create", PermissionScope.USER, true); + super("create", true); } @Override 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 index 06d9ae1..816c8a1 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -2,7 +2,6 @@ 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; @@ -15,7 +14,7 @@ @EndpointPath("/v2/project/{project_id}") public class DeleteProjectEndpoint extends AuthorizedProjectEndpoint { public DeleteProjectEndpoint() { - super("{project_id}", PermissionScope.ALL, false); + super("{project_id}", false); } @Override 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 index 21bb4f4..3dff5fa 100644 --- 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 @@ -3,7 +3,6 @@ import com.mojang.serialization.Codec; 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.data.user.User; import net.modgarden.backend.endpoint.EndpointMethod; @@ -17,7 +16,7 @@ @EndpointPath("/v2/project/{project_id}/add_member") public class AddMemberEndpoint extends AuthorizedProjectEndpoint { public AddMemberEndpoint() { - super("{project_id}/add_member", PermissionScope.ALL, true); + super("{project_id}/add_member", true); } @Override 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 index dc72474..af29440 100644 --- 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 @@ -3,7 +3,6 @@ import com.mojang.serialization.Codec; 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.data.user.User; import net.modgarden.backend.endpoint.EndpointMethod; @@ -19,7 +18,7 @@ @EndpointPath("/v2/project/{project_id}/remove_member") public class RemoveMemberEndpoint extends AuthorizedProjectEndpoint { public RemoveMemberEndpoint() { - super("{project_id}/remove_member", PermissionScope.ALL, true); + super("{project_id}/remove_member", true); } @Override 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 index f379346..8d98309 100644 --- 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 @@ -3,7 +3,6 @@ import com.mojang.serialization.Codec; 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.data.user.User; import net.modgarden.backend.endpoint.EndpointMethod; @@ -11,7 +10,6 @@ import net.modgarden.backend.endpoint.v2.AuthorizedProjectEndpoint; import org.jetbrains.annotations.NotNull; -import java.sql.ResultSet; import java.util.Map; import static net.modgarden.backend.endpoint.EndpointMethod.Method.PUT; @@ -20,7 +18,7 @@ @EndpointPath("/v2/project/{project_id}/set_permissions") public class SetPermissionsEndpoint extends AuthorizedProjectEndpoint { public SetPermissionsEndpoint() { - super("{project_id}/set_permissions", PermissionScope.ALL, true); + super("{project_id}/set_permissions", true); } @Override 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 index 35dcd82..06dd6fa 100644 --- 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 @@ -3,7 +3,6 @@ import com.mojang.serialization.Codec; 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.data.user.User; import net.modgarden.backend.endpoint.EndpointMethod; @@ -19,7 +18,7 @@ @EndpointPath("/v2/project/{project_id}/set_role") public class SetRoleEndpoint extends AuthorizedProjectEndpoint { public SetRoleEndpoint() { - super("{project_id}/set_role", PermissionScope.ALL, true); + super("{project_id}/set_role", true); } @Override From 67cd0094285974b4c4d65f4bef37f3343c5ff7c9 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 19:55:22 -0500 Subject: [PATCH 94/98] fix: be compliant with HTTP status 201 Location header --- .../backend/endpoint/v2/project/CreateProjectEndpoint.java | 1 + 1 file changed, 1 insertion(+) 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 index 885a39b..0760726 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java @@ -53,6 +53,7 @@ INSERT OR IGNORE INTO project_roles (project_id, user_id, permissions) projectRolesStatement.executeUpdate(); ctx.status(201); + ctx.header("Location", "/v2/project/" + generatedProjectId); } } From c318dfe0bdc14a11a296aee4a103ec2dcedbe46f Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 20:01:07 -0500 Subject: [PATCH 95/98] docs: todo for db access class in /v2/project --- .../backend/endpoint/v2/project/GetProjectEndpoint.java | 1 + 1 file changed, 1 insertion(+) 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 index 83953aa..f8745dc 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -23,6 +23,7 @@ public GetProjectEndpoint(String path) { public abstract void handle(@NotNull Context ctx) throws Exception; // TODO: Require view project permissions or being a member of the project to view draft projects. + // todo: cali why is this not in DatabaseAccess :tiny_pineapple: public static Project getProjectFromId(@NotNull Connection connection, @NotNull String projectId) throws Exception { Map team = new HashMap<>(); From 4aefe7cd0678d7f0cc418ad88f5cabe21b8f2d22 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 20:17:18 -0500 Subject: [PATCH 96/98] fix: set project ID for permission scopes --- .../backend/database/DatabaseAccess.java | 155 ++++++++++++++++++ .../backend/endpoint/AuthorizedEndpoint.java | 23 +-- .../v2/AuthorizedProjectEndpoint.java | 20 ++- .../v2/AuthorizedSubmissionEndpoint.java | 7 + .../event/GetSubmissionByModIdEndpoint.java | 3 +- .../v2/project/CreateProjectEndpoint.java | 11 +- .../v2/project/DeleteProjectEndpoint.java | 8 +- .../v2/project/GetProjectByIdEndpoint.java | 2 +- .../v2/project/GetProjectByModIdEndpoint.java | 2 +- .../v2/project/GetProjectEndpoint.java | 85 +--------- .../v2/project/member/AddMemberEndpoint.java | 9 +- .../project/member/RemoveMemberEndpoint.java | 10 +- .../member/SetPermissionsEndpoint.java | 14 +- .../v2/project/member/SetRoleEndpoint.java | 10 +- .../submission/DeleteSubmissionEndpoint.java | 8 + .../submission/GetSubmissionByIdEndpoint.java | 3 +- .../v2/submission/GetSubmissionEndpoint.java | 52 ------ 17 files changed, 251 insertions(+), 171 deletions(-) diff --git a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java index f8c78f2..b414065 100644 --- a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java +++ b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java @@ -2,17 +2,172 @@ 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 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, NullPointerException { + 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 NullPointerException("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 NullPointerException("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 NullPointerException("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(); diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index 86d2eaf..dd6a7eb 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -1,9 +1,5 @@ package net.modgarden.backend.endpoint; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; import de.mkammerer.argon2.Argon2Advanced; import de.mkammerer.argon2.Argon2Factory; import io.javalin.http.Context; @@ -14,6 +10,7 @@ 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; @@ -91,6 +88,10 @@ public final void handle(@NotNull Context ctx) throws Exception { this.handle(ctx, validationResult.userId(), validationResult.scopePermissions()); } + protected @Nullable String getProjectId(Context ctx) throws SQLException { + return null; + } + /// # Caution /// Modifying this method is a dangerous game. /// @@ -133,19 +134,7 @@ private ValidationResult validateAuth(Context ctx) throws SQLException { return new ValidationResult(true, "grbot", scopePermissions); } - JsonObject body; - String projectId = null; - if (this.hasBody) { - try { - body = JsonParser.parseString(ctx.body()).getAsJsonObject(); - JsonElement projectIdElement = body.get("project_id"); - if (projectIdElement != null) { - projectId = projectIdElement.getAsString(); - } - } catch (JsonSyntaxException | IllegalStateException e) { - this.invalidBody(ctx, e.getMessage()); - } - } + String projectId = this.getProjectId(ctx); String idSecretPair = authorization.split(" ")[1]; String[] idSecretPairSplit = idSecretPair.split(":"); diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java index 9a6ecf2..30fed35 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java @@ -14,17 +14,25 @@ @EndpointPath("/v2/project") public abstract class AuthorizedProjectEndpoint extends AuthorizedEndpoint { public AuthorizedProjectEndpoint(String path, boolean hasBody) { - super(2, "project/" + path, PermissionScope.PROJECT, hasBody); + this(path, PermissionScope.PROJECT, hasBody); + } + + protected AuthorizedProjectEndpoint(String path, PermissionScope scope, boolean hasBody) { + super(2, "project/" + path, scope, hasBody); } @Override public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; - protected static boolean canModifyUser( + @NotNull + @Override + protected abstract String getProjectId(Context ctx); + + protected static boolean requireUserCanModifyMember( Context ctx, Connection connection, String projectId, - String userIdToModify, + String memberUserIdToModify, Permissions selfPermissions ) throws Exception { try ( @@ -35,7 +43,7 @@ protected static boolean canModifyUser( """) ) { memberPermissionsStatement.setString(1, projectId); - memberPermissionsStatement.setString(2, userIdToModify); + memberPermissionsStatement.setString(2, memberUserIdToModify); ResultSet memberPermissionsResult = memberPermissionsStatement.executeQuery(); Permissions memberPermissions = new Permissions(memberPermissionsResult.getLong(1)); @@ -43,10 +51,10 @@ protected static boolean canModifyUser( if (memberPermissions.hasPermissions(Permission.ADMINISTRATOR) && !selfPermissions.hasPermissions(Permission.ADMINISTRATOR)) { ctx.status(403); ctx.result("Non-administrators may not edit administrators' permissions on projects"); - return false; + return true; } } - 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 index 3b9092f..8610b1f 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java @@ -6,6 +6,9 @@ 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 { @@ -13,6 +16,10 @@ public AuthorizedSubmissionEndpoint(String path, PermissionScope permissionScope super(2, "submission/" + path, permissionScope, hasBody); } + @NotNull + @Override + protected abstract String getProjectId(Context ctx) throws SQLException; + @Override public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; } 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 index 912dd3a..ff8aed1 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java @@ -2,6 +2,7 @@ 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; @@ -77,7 +78,7 @@ public void handle(@NotNull Context ctx) throws Exception { return; } - Submission submission = GetSubmissionEndpoint.getSubmissionFromId(connection, submissionId); + 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 index 0760726..942087b 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java @@ -3,11 +3,13 @@ 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; @@ -15,7 +17,7 @@ @EndpointPath("/v2/project/create") public class CreateProjectEndpoint extends AuthorizedProjectEndpoint { public CreateProjectEndpoint() { - super("create", true); + super("create", PermissionScope.USER, true); } @Override @@ -57,6 +59,13 @@ INSERT OR IGNORE INTO project_roles (project_id, user_id, permissions) } } + @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 index 816c8a1..ecc3c52 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -23,7 +23,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss if (this.requireAnyPermissions(ctx, scopePermissions, Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; - String projectId = ctx.pathParam("project_id"); + String projectId = this.getProjectId(ctx); try ( var connection = this.getDatabaseConnection(); @@ -36,4 +36,10 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss 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 index e69fdbe..9e8f91e 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java @@ -36,7 +36,7 @@ public void handle(@NotNull Context ctx) throws Exception { return; } - Project project = GetProjectEndpoint.getProjectFromId(connection, projectId); + 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 index 44c1ee1..84b40df 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java @@ -39,7 +39,7 @@ public void handle(@NotNull Context ctx) throws Exception { return; } - Project project = GetProjectEndpoint.getProjectFromId(connection, projectId); + 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 index f8745dc..db6f509 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -1,18 +1,11 @@ package net.modgarden.backend.endpoint.v2.project; import io.javalin.http.Context; -import net.modgarden.backend.data.Metadata; -import net.modgarden.backend.data.event.Project; -import net.modgarden.backend.data.event.metadata.DraftMetadata; -import net.modgarden.backend.data.event.metadata.ModMetadata; import net.modgarden.backend.endpoint.Endpoint; import net.modgarden.backend.endpoint.EndpointPath; import org.jetbrains.annotations.NotNull; -import java.sql.Connection; -import java.sql.ResultSet; -import java.util.*; - +// 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) { @@ -21,80 +14,4 @@ public GetProjectEndpoint(String path) { @Override public abstract void handle(@NotNull Context ctx) throws Exception; - - // TODO: Require view project permissions or being a member of the project to view draft projects. - // todo: cali why is this not in DatabaseAccess :tiny_pineapple: - public static Project getProjectFromId(@NotNull Connection connection, - @NotNull String projectId) throws Exception { - 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 NullPointerException("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 - ); - } - } } 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 index 3dff5fa..7f5d9bf 100644 --- 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 @@ -9,6 +9,7 @@ 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; @@ -25,7 +26,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss if (this.requireAnyPermissions(ctx, scopePermissions, Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; - String projectId = ctx.pathParam("project_id"); + String projectId = this.getProjectId(ctx); Request request = decodeBody(ctx, Request.CODEC) .unwrap(ctx); @@ -44,6 +45,12 @@ INSERT OR IGNORE INTO project_roles (project_id, user_id) } } + @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 index af29440..d7f81ba 100644 --- 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 @@ -25,7 +25,7 @@ public RemoveMemberEndpoint() { public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode - String projectId = ctx.pathParam("project_id"); + String projectId = this.getProjectId(ctx); Request request = decodeBody(ctx, Request.CODEC) .unwrap(ctx); @@ -55,7 +55,7 @@ SELECT COUNT(*) Permissions memberPermissions = new Permissions(memberPermissionsResult.getLong(1)); // If a non-administrator attempts to remove an administrator, return. - if (!canModifyUser(ctx, connection, projectId, request.userId(), scopePermissions)) return; + if (requireUserCanModifyMember(ctx, connection, projectId, request.userId(), scopePermissions)) return; boolean memberIsAdmin = memberPermissions.hasPermissions(Permission.ADMINISTRATOR); @@ -77,6 +77,12 @@ SELECT COUNT(*) } } + @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 index 8d98309..cf15b2c 100644 --- 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 @@ -43,7 +43,13 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss """) ) { for (Map.Entry usersToPermissions : request.usersToPermissions().entrySet()) { - if (!canModifyUser(ctx, connection, projectId, usersToPermissions.getKey(), scopePermissions)) return; + if (requireUserCanModifyMember( + ctx, + connection, + projectId, + usersToPermissions.getKey(), + scopePermissions + )) return; updateStatement.setLong(1, usersToPermissions.getValue().bits()); updateStatement.setString(2, projectId); @@ -53,6 +59,12 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss } } + @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 index 06dd6fa..92ee275 100644 --- 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 @@ -27,7 +27,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss if (this.requireAnyPermissions(ctx, scopePermissions, Permission.EDIT_PROJECT, Permission.MODERATE_PROJECTS)) return; - String projectId = ctx.pathParam("project_id"); + String projectId = this.getProjectId(ctx); Request request = decodeBody(ctx, Request.CODEC) .unwrap(ctx); @@ -43,7 +43,7 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss """) ) { for (Map.Entry usersToRoleName : request.usersToRoleName().entrySet()) { - if (!canModifyUser(ctx, connection, projectId, usersToRoleName.getKey(), scopePermissions)) return; + if (requireUserCanModifyMember(ctx, connection, projectId, usersToRoleName.getKey(), scopePermissions)) return; updateStatement.setString(1, usersToRoleName.getValue()); updateStatement.setString(2, projectId); @@ -53,6 +53,12 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss } } + @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 index 2576f0f..adf013d 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java @@ -9,6 +9,8 @@ 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) @@ -37,4 +39,10 @@ public void handle(@NotNull Context ctx, String userId, Permissions scopePermiss 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 index 84dc51a..e918909 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java @@ -2,6 +2,7 @@ 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; @@ -38,7 +39,7 @@ public void handle(@NotNull Context ctx) throws Exception { return; } - Submission submission = GetSubmissionEndpoint.getSubmissionFromId(connection, submissionId); + 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 index a5256f2..a9b238e 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java @@ -1,17 +1,9 @@ package net.modgarden.backend.endpoint.v2.submission; import io.javalin.http.Context; -import net.modgarden.backend.data.Platform; -import net.modgarden.backend.data.event.Submission; -import net.modgarden.backend.data.event.platform.ModrinthPlatform; import net.modgarden.backend.endpoint.Endpoint; -import net.modgarden.backend.endpoint.v2.project.GetProjectEndpoint; -import net.modgarden.backend.util.ModrinthUtils; import org.jetbrains.annotations.NotNull; -import java.sql.Connection; -import java.sql.ResultSet; - public abstract class GetSubmissionEndpoint extends Endpoint { public GetSubmissionEndpoint(String path) { super(2, path); @@ -19,48 +11,4 @@ public GetSubmissionEndpoint(String path) { @Override public abstract void handle(@NotNull Context ctx) throws Exception; - - public static Submission getSubmissionFromId(@NotNull Connection connection, - @NotNull String submissionId) throws Exception { - 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 NullPointerException("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"), - GetProjectEndpoint.getProjectFromId(connection, submissionResult.getString("project_id")), - platform - ); - } - } } From 14275840f3178076e5cb920cd03b55df2ef84213 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 20:37:17 -0500 Subject: [PATCH 97/98] fix: make this a javadoc --- src/main/java/net/modgarden/backend/data/Permission.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/data/Permission.java b/src/main/java/net/modgarden/backend/data/Permission.java index 6ccc4b4..fc9b7d8 100644 --- a/src/main/java/net/modgarden/backend/data/Permission.java +++ b/src/main/java/net/modgarden/backend/data/Permission.java @@ -27,7 +27,7 @@ public enum Permission { 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 events and hide them. EDIT_EVENT(0x100, "edit_event", USER); /// The default permissions that all users have. From 64f812457be6a234c0506b5e730a6df36bf84341 Mon Sep 17 00:00:00 2001 From: sylv256 Date: Sat, 22 Nov 2025 21:06:31 -0500 Subject: [PATCH 98/98] fix: call handle super method & handle exceptions --- .../modgarden/backend/database/DatabaseAccess.java | 9 +++++---- .../backend/endpoint/AuthorizedEndpoint.java | 7 +++---- .../net/modgarden/backend/endpoint/Endpoint.java | 12 +++++++++++- .../endpoint/exception/NotFoundException.java | 7 +++++++ .../modgarden/backend/endpoint/v2/AuthEndpoint.java | 2 +- .../endpoint/v2/AuthorizedProjectEndpoint.java | 2 +- .../endpoint/v2/AuthorizedSubmissionEndpoint.java | 2 +- .../backend/endpoint/v2/auth/DeleteKeyEndpoint.java | 2 +- .../endpoint/v2/auth/GenerateKeyEndpoint.java | 2 +- .../backend/endpoint/v2/auth/ListKeysEndpoint.java | 2 +- .../v2/event/GetSubmissionByModIdEndpoint.java | 2 +- .../endpoint/v2/project/CreateProjectEndpoint.java | 2 +- .../endpoint/v2/project/DeleteProjectEndpoint.java | 2 +- .../endpoint/v2/project/GetProjectByIdEndpoint.java | 2 +- .../v2/project/GetProjectByModIdEndpoint.java | 2 +- .../endpoint/v2/project/GetProjectEndpoint.java | 2 +- .../v2/project/member/AddMemberEndpoint.java | 2 +- .../v2/project/member/RemoveMemberEndpoint.java | 2 +- .../v2/project/member/SetPermissionsEndpoint.java | 2 +- .../endpoint/v2/project/member/SetRoleEndpoint.java | 2 +- .../v2/submission/DeleteSubmissionEndpoint.java | 2 +- .../v2/submission/GetSubmissionByIdEndpoint.java | 2 +- .../v2/submission/GetSubmissionEndpoint.java | 2 +- 23 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/endpoint/exception/NotFoundException.java diff --git a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java index b414065..c6e29f9 100644 --- a/src/main/java/net/modgarden/backend/database/DatabaseAccess.java +++ b/src/main/java/net/modgarden/backend/database/DatabaseAccess.java @@ -10,6 +10,7 @@ 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; @@ -27,7 +28,7 @@ public Connection getDatabaseConnection() throws SQLException { public String getProjectIdFromSubmissionId( @NotNull String submissionId - ) throws SQLException, NullPointerException { + ) throws SQLException, NotFoundException { Connection connection = this.getDatabaseConnection(); try ( var submissionIdStatement = connection.prepareStatement(""" @@ -39,7 +40,7 @@ public String getProjectIdFromSubmissionId( submissionIdStatement.setString(1, submissionId); ResultSet submissionResult = submissionIdStatement.executeQuery(); if (!submissionResult.isBeforeFirst()) { - throw new NullPointerException("Could not find submission '" + submissionId + "'"); + throw new NotFoundException("Could not find submission '" + submissionId + "'"); } return submissionResult.getString("project_id"); @@ -65,7 +66,7 @@ public Submission getSubmissionFromId( submissionStatement.setString(1, submissionId); ResultSet submissionResult = submissionStatement.executeQuery(); if (!submissionResult.isBeforeFirst()) { - throw new NullPointerException("Could not find submission '" + submissionId + "'"); + throw new NotFoundException("Could not find submission '" + submissionId + "'"); } modrinthSubmissionTypeStatement.setString(1, submissionId); @@ -140,7 +141,7 @@ public Project getProjectFromId( projectDraftMetadataResult.getString("name") ); } else { - throw new NullPointerException("Could not find metadata for project '" + projectId + "'"); + throw new NotFoundException("Could not find metadata for project '" + projectId + "'"); } diff --git a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java index dd6a7eb..6133b30 100644 --- a/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/AuthorizedEndpoint.java @@ -75,17 +75,16 @@ protected static boolean verifySecret(String hash, String secret) { return ARGON.verify(hash, secret.toCharArray()); } - protected abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; + protected abstract void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; @Override - public final void handle(@NotNull Context ctx) throws Exception { + public final void onRequest(@NotNull Context ctx) throws Exception { ValidationResult validationResult = validateAuth(ctx); if (!validationResult.authorized()) { return; } - super.handle(ctx); - this.handle(ctx, validationResult.userId(), validationResult.scopePermissions()); + this.onRequest(ctx, validationResult.userId(), validationResult.scopePermissions()); } protected @Nullable String getProjectId(Context ctx) throws SQLException { diff --git a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java index 27d21d8..d034ee1 100644 --- a/src/main/java/net/modgarden/backend/endpoint/Endpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/Endpoint.java @@ -10,6 +10,7 @@ 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; @@ -33,7 +34,7 @@ public Endpoint(int version, String path) { } @Override - public void handle(@NotNull Context ctx) throws Exception { + 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)) { @@ -42,8 +43,17 @@ public void handle(@NotNull Context ctx) throws Exception { 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; } 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 index 8b48e32..4d65132 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthEndpoint.java @@ -14,5 +14,5 @@ public AuthEndpoint(String path, PermissionScope permissionScope, boolean hasBod } @Override - public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; + 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 index 30fed35..5d39354 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedProjectEndpoint.java @@ -22,7 +22,7 @@ protected AuthorizedProjectEndpoint(String path, PermissionScope scope, boolean } @Override - public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; + public abstract void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; @NotNull @Override diff --git a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java index 8610b1f..cf3a0ee 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/AuthorizedSubmissionEndpoint.java @@ -21,5 +21,5 @@ public AuthorizedSubmissionEndpoint(String path, PermissionScope permissionScope protected abstract String getProjectId(Context ctx) throws SQLException; @Override - public abstract void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception; + 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 index 92bd6b5..613e22e 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/DeleteKeyEndpoint.java @@ -23,7 +23,7 @@ public DeleteKeyEndpoint() { } @Override - public void handle( + public void onRequest( @NotNull Context ctx, String userId, Permissions scopePermissions 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 index 8011e4b..6ec48a6 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/GenerateKeyEndpoint.java @@ -32,7 +32,7 @@ public GenerateKeyEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + 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) 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 index 3aab21d..67cc2a5 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/auth/ListKeysEndpoint.java @@ -30,7 +30,7 @@ public ListKeysEndpoint() { } @Override - public void handle( + public void onRequest( @NotNull Context ctx, String userId, Permissions scopePermissions 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 index ff8aed1..7b42061 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/event/GetSubmissionByModIdEndpoint.java @@ -21,7 +21,7 @@ public GetSubmissionByModIdEndpoint() { @SuppressWarnings("DuplicatedCode") @Override - public void handle(@NotNull Context ctx) throws Exception { + 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); 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 index 942087b..0c9da17 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/CreateProjectEndpoint.java @@ -21,7 +21,7 @@ public CreateProjectEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + 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); 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 index ecc3c52..d0f3276 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/DeleteProjectEndpoint.java @@ -18,7 +18,7 @@ public DeleteProjectEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + 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; 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 index 9e8f91e..12e91d8 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByIdEndpoint.java @@ -18,7 +18,7 @@ public GetProjectByIdEndpoint() { } @Override - public void handle(@NotNull Context ctx) throws Exception { + public void onRequest(@NotNull Context ctx) throws Exception { String projectId = ctx.pathParam("project_id"); try ( var connection = this.getDatabaseConnection(); 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 index 84b40df..5a0f5c9 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectByModIdEndpoint.java @@ -18,7 +18,7 @@ public GetProjectByModIdEndpoint() { } @Override - public void handle(@NotNull Context ctx) throws Exception { + public void onRequest(@NotNull Context ctx) throws Exception { String modId = ctx.pathParam("mod_id"); try ( 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 index db6f509..b58b8d9 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/project/GetProjectEndpoint.java @@ -13,5 +13,5 @@ public GetProjectEndpoint(String path) { } @Override - public abstract void handle(@NotNull Context ctx) throws Exception; + 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 index 7f5d9bf..9dd72f2 100644 --- 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 @@ -21,7 +21,7 @@ public AddMemberEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + 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; 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 index d7f81ba..7b410bb 100644 --- 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 @@ -22,7 +22,7 @@ public RemoveMemberEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + public void onRequest(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { //noinspection DuplicatedCode String projectId = this.getProjectId(ctx); 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 index cf15b2c..5add67c 100644 --- 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 @@ -22,7 +22,7 @@ public SetPermissionsEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + 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; 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 index 92ee275..bf9eec4 100644 --- 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 @@ -22,7 +22,7 @@ public SetRoleEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + 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; 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 index adf013d..6db4366 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/DeleteSubmissionEndpoint.java @@ -21,7 +21,7 @@ public DeleteSubmissionEndpoint() { } @Override - public void handle(@NotNull Context ctx, String userId, Permissions scopePermissions) throws Exception { + 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; 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 index e918909..dfdce2f 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionByIdEndpoint.java @@ -19,7 +19,7 @@ public GetSubmissionByIdEndpoint() { } @Override - public void handle(@NotNull Context ctx) throws Exception { + public void onRequest(@NotNull Context ctx) throws Exception { String submissionId = ctx.pathParam("submission_id").toLowerCase(Locale.ROOT); try ( 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 index a9b238e..3a0d48e 100644 --- a/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java +++ b/src/main/java/net/modgarden/backend/endpoint/v2/submission/GetSubmissionEndpoint.java @@ -10,5 +10,5 @@ public GetSubmissionEndpoint(String path) { } @Override - public abstract void handle(@NotNull Context ctx) throws Exception; + public abstract void onRequest(@NotNull Context ctx) throws Exception; }