diff --git a/.github/tests/dev-responsiveness-diagnostic.test.mjs b/.github/tests/dev-responsiveness-diagnostic.test.mjs index d104a0f6e..dbfdcbe1a 100644 --- a/.github/tests/dev-responsiveness-diagnostic.test.mjs +++ b/.github/tests/dev-responsiveness-diagnostic.test.mjs @@ -179,7 +179,7 @@ test('workflow is manual, develop-only, credential-free and standard-runner with assert.ok(!/^ (push|pull_request|schedule):/m.test(workflow)); assert.match(workflow, /github\.ref_name == 'develop'/); assert.match(workflow, /runs-on: ubuntu-latest/); - assert.match(workflow, /node-version: 22\.23\.3/); + assert.match(workflow, /node-version: 22\.23\.4/); assert.ok(!/DIAGNOSTIC_TOOL_ROOT: \$\{\{ runner\./.test(workflow)); assert.match(workflow, /export DIAGNOSTIC_TOOL_ROOT="\$RUNNER_TEMP\/modtale-diagnostic-tools"/); assert.match(workflow, /contents: read/); diff --git a/.github/workflows/api-cache-header-diagnostic.yml b/.github/workflows/api-cache-header-diagnostic.yml index da54c61ec..fae7ede38 100644 --- a/.github/workflows/api-cache-header-diagnostic.yml +++ b/.github/workflows/api-cache-header-diagnostic.yml @@ -32,7 +32,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22.23.3 + node-version: 22.23.4 - name: Validate fixed environment and unit guards run: | node .github/scripts/api-cache-header-diagnostic.mjs validate diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index af4d46bb1..04b5a558f 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,4 +1,4 @@ -name: Modtale CI/CD +name: Modtale CI/CD on: push: @@ -56,6 +56,11 @@ jobs: launcher_artifact_prefix: ${{ steps.launcher_metadata.outputs.artifact_prefix }} steps: + - name: Set up patched Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.23.4 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -105,7 +110,7 @@ jobs: fi echo "GIT_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV echo "BRANCH_SLUG=$BRANCH_SLUG" >> $GITHUB_ENV - + if [ "$BRANCH_NAME" = "main" ]; then echo "ENV_TYPE=prod" >> $GITHUB_ENV echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV @@ -130,7 +135,7 @@ jobs: echo "WARDEN_ENABLED=true" >> $GITHUB_ENV echo "OAUTH_ENABLED=true" >> $GITHUB_ENV echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV - + elif [ "$BRANCH_NAME" = "develop" ]; then echo "ENV_TYPE=dev" >> $GITHUB_ENV echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV @@ -155,7 +160,7 @@ jobs: echo "WARDEN_ENABLED=true" >> $GITHUB_ENV echo "OAUTH_ENABLED=true" >> $GITHUB_ENV echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV - + else SLUG="$BRANCH_SLUG" BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME }}" @@ -511,7 +516,7 @@ jobs: else echo "Reusing the existing backend image for a configuration-only rollout." fi - + ARGS=( "--image" "gcr.io/$PROJECT_ID/modtale-backend:${{ env.TAG }}" "--region" "$REGION" @@ -857,7 +862,7 @@ jobs: else PUBLIC_BACKEND_URL=$B_URL fi - + if [ "$MODTALE_SECRET_BUNDLES_ENABLED" = "true" ]; then python3 .github/scripts/secret_bundle_ci.py deploy -- \ --update-env-vars "FRONTEND_URL=$FINAL_FRONTEND_URL,BACKEND_URL=$PUBLIC_BACKEND_URL" >/dev/null @@ -953,7 +958,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "| Component | Status | Target Service | Service URL |" >> $GITHUB_STEP_SUMMARY echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY - + if [ -n "$PUBLIC_API_URL" ]; then echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($PUBLIC_API_URL) |" >> $GITHUB_STEP_SUMMARY elif [ -n "$B_URL" ]; then @@ -961,13 +966,13 @@ jobs: else echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + if [ -n "$F_URL" ]; then echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | [App URL]($F_URL) |" >> $GITHUB_STEP_SUMMARY else echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + echo "" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY @@ -1154,6 +1159,11 @@ jobs: R2_TOKEN_NAME: modtale-preview-template-reader RUNTIME_SERVICE_ACCOUNT: ${{ vars.GCP_BRANCH_PREVIEW_RUNTIME_SERVICE_ACCOUNT }} steps: + - name: Set up patched Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.23.4 + - uses: actions/checkout@v4 - name: Resolve environment-specific bundle settings env: @@ -1246,6 +1256,11 @@ jobs: id-token: write steps: + - name: Set up patched Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.23.4 + - name: Prepare cleanup names env: DELETED_BRANCH: ${{ matrix.branch }} diff --git a/.github/workflows/dev-responsiveness-diagnostic.yml b/.github/workflows/dev-responsiveness-diagnostic.yml index 85f3f1c97..5c0d33851 100644 --- a/.github/workflows/dev-responsiveness-diagnostic.yml +++ b/.github/workflows/dev-responsiveness-diagnostic.yml @@ -54,7 +54,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22.23.3 + node-version: 22.23.4 - name: Validate fixed dev target and bounded inputs run: node .github/scripts/dev-responsiveness-diagnostic.mjs validate diff --git a/.github/workflows/mock-db-refresh.yml b/.github/workflows/mock-db-refresh.yml index d5c5cb7f4..df6d7c0ca 100644 --- a/.github/workflows/mock-db-refresh.yml +++ b/.github/workflows/mock-db-refresh.yml @@ -95,7 +95,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22.12.0 + node-version: 22.23.4 - name: Install mock DB tooling working-directory: mock-db diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 6527aae5f..654ea7bf7 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -38,6 +38,11 @@ jobs: head_repo: ${{ steps.resolve.outputs.head_repo }} head_sha: ${{ steps.resolve.outputs.head_sha }} steps: + - name: Set up patched Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.23.4 + - id: resolve name: Validate maintainer command env: @@ -139,6 +144,11 @@ jobs: PR_HEAD_SHA: ${{ needs.command.outputs.head_sha }} steps: + - name: Set up patched Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.23.4 + - name: Validate preview isolation config run: | : "${PROJECT_ID:?Set repository variable GCP_PREVIEW_PROJECT_ID to a dedicated preview GCP project.}" @@ -493,6 +503,11 @@ jobs: CLOUDFLARE_R2_JURISDICTION: ${{ vars.CLOUDFLARE_R2_JURISDICTION }} steps: + - name: Set up patched Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.23.4 + - name: Check out trusted cleanup scripts uses: actions/checkout@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3e9796427..7a7220bf9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,7 +44,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22.12.0 + node-version: 22.23.4 - name: Test workflow and fixture scripts run: node --test .github/tests/*.test.mjs mock-db/tests/*.test.mjs @@ -91,7 +91,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22.12.0 + node-version: 22.23.4 cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/backend/Dockerfile b/backend/Dockerfile index f8d77a823..17e4d2345 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,9 @@ FROM docker.io/library/eclipse-temurin:26-jdk-jammy AS builder WORKDIR /app +COPY security/blocked-certificates.properties /tmp/blocked-certificates.properties +RUN printf '\n' >> "$JAVA_HOME/lib/security/blocked.certs" \ + && cat /tmp/blocked-certificates.properties >> "$JAVA_HOME/lib/security/blocked.certs" \ + && rm /tmp/blocked-certificates.properties COPY gradlew settings.gradle build.gradle ./ COPY gradle ./gradle @@ -13,6 +17,10 @@ RUN ./gradlew test bootJar --no-daemon FROM docker.io/library/eclipse-temurin:26-jre-jammy WORKDIR /app +COPY security/blocked-certificates.properties /tmp/blocked-certificates.properties +RUN printf '\n' >> "$JAVA_HOME/lib/security/blocked.certs" \ + && cat /tmp/blocked-certificates.properties >> "$JAVA_HOME/lib/security/blocked.certs" \ + && rm /tmp/blocked-certificates.properties COPY --from=builder /app/build/libs/*.jar app.jar RUN mkdir -p /app/secrets diff --git a/backend/Dockerfile.status b/backend/Dockerfile.status index 089400d3c..3dbb693f5 100644 --- a/backend/Dockerfile.status +++ b/backend/Dockerfile.status @@ -1,5 +1,9 @@ FROM docker.io/library/eclipse-temurin:26-jdk-jammy AS builder WORKDIR /app +COPY security/blocked-certificates.properties /tmp/blocked-certificates.properties +RUN printf '\n' >> "$JAVA_HOME/lib/security/blocked.certs" \ + && cat /tmp/blocked-certificates.properties >> "$JAVA_HOME/lib/security/blocked.certs" \ + && rm /tmp/blocked-certificates.properties COPY gradlew settings.gradle build.gradle ./ COPY gradle ./gradle @@ -13,6 +17,10 @@ RUN ./gradlew test statusServiceJar --no-daemon FROM docker.io/library/eclipse-temurin:26-jre-jammy WORKDIR /app +COPY security/blocked-certificates.properties /tmp/blocked-certificates.properties +RUN printf '\n' >> "$JAVA_HOME/lib/security/blocked.certs" \ + && cat /tmp/blocked-certificates.properties >> "$JAVA_HOME/lib/security/blocked.certs" \ + && rm /tmp/blocked-certificates.properties COPY --from=builder /app/build/libs/*-status.jar app.jar ENV SPRING_PROFILES_ACTIVE=status diff --git a/backend/build.gradle b/backend/build.gradle index a92a331e1..2dc1a79e7 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -8,9 +8,9 @@ group = 'net.modtale' version = '0.0.1-SNAPSHOT' // Keep the embedded Tomcat family aligned while Boot 4.1.1 manages 11.0.24. -// 11.0.26 includes the security fixes in 11.0.25 and the latest 11.0.x fixes: +// 11.0.27 includes the HTTP/2 request/response isolation fixes disclosed in October: // https://tomcat.apache.org/security-11.html -ext['tomcat.version'] = '11.0.26' +ext['tomcat.version'] = '11.0.27' java { toolchain { diff --git a/backend/security/blocked-certificates.properties b/backend/security/blocked-certificates.properties new file mode 100644 index 000000000..557f6bee5 --- /dev/null +++ b/backend/security/blocked-certificates.properties @@ -0,0 +1,18 @@ +# CVE-2026-91403: eMudhra intermediates and Sectigo cross-signs. +# Verified against OpenJDK src/java.base/share/data/blockedcertsconverter/blocked.certs.pem. +42C4ACCD12C4F59597925E1A08A7A90102608543809308D50EC427E118BA1493= +45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE= +98B896625C0C230ADEEC3EB2CF6AAD5001ECF9156A68644E82374CE5833BC25B= +5F1DED4DD590AF74631290CF073B122A2AAC6A1FA479778A0BD43579BD832699= +E7568FE6E12331F4FD7DE61DA4C7559D0F8EB192A218D4741A3BA75184333838= +579480AEA166290BD3C643589E795FC0D0E54E86505D180F1DFF99EFD3199512= +0164D7CEAE5BB909C272AF151FF44B1177AE9B47D9424B94F1D9558C30934D19= +62B7F9868263F72F6911FC78CEAF72C1979158B7368BF648683BB2AA39B02E72= +CB6E15165AC802E9B3F993F16C25D41F4E81C21EBDE71AE1B012AC00BF9C7C3F= +7485B51C0ED192878FA1D2F948FB529AE1842CC5086C4B67F7BE5B2039DE9489= +432EC25F0D28DE9006D97BF752F17BBB2CD41F669933FCB072D9A1148D0261F3= +FD9D5C2DBBD4995AB69B8D50C439D9527B00529199C2FD01B2174EB58F07021F= +9E39DE2881273ACC225A149C72BA3E0F51424963669AB633EE1DC7DF5DB067B4= +A496E23AC0CAB8155FC9B117B97EAE368EFCBB9041AEC8804D5A308D2D01C2D8= +4B2460A466F95FD40113BCDCCA2CEB29B3B093921DE45474DA7DF48874C630B1= +84112972B8E59D14560AE7E7119654474AAD119990CF3722BFC1E3B91615E1FD= diff --git a/backend/src/main/java/net/modtale/ModtaleApplication.java b/backend/src/main/java/net/modtale/ModtaleApplication.java index fbdd90ce4..25c424e48 100644 --- a/backend/src/main/java/net/modtale/ModtaleApplication.java +++ b/backend/src/main/java/net/modtale/ModtaleApplication.java @@ -14,6 +14,7 @@ pattern = "net\\.modtale\\.status\\..*" )) @ConfigurationPropertiesScan +@EnableCaching @EnableMethodSecurity public class ModtaleApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/net/modtale/config/properties/AppModjamDiscordFeedProperties.java b/backend/src/main/java/net/modtale/config/properties/AppModjamDiscordFeedProperties.java new file mode 100644 index 000000000..cea1977b3 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/properties/AppModjamDiscordFeedProperties.java @@ -0,0 +1,16 @@ +package net.modtale.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "app.modjam-discord-feed") +public record AppModjamDiscordFeedProperties( + @DefaultValue("false") boolean enabled, + @DefaultValue("") String url +) { + @Override + public String toString() { + return "AppModjamDiscordFeedProperties[enabled=" + enabled + ", urlConfigured=" + + (url != null && !url.isBlank()) + "]"; + } +} diff --git a/backend/src/main/java/net/modtale/config/security/PartitionedCsrfTokenRepository.java b/backend/src/main/java/net/modtale/config/security/PartitionedCsrfTokenRepository.java new file mode 100644 index 000000000..af7791342 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/security/PartitionedCsrfTokenRepository.java @@ -0,0 +1,48 @@ +package net.modtale.config.security; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRepository; + +/** Preserves CHIPS when Spring Security 7.1's Servlet cookie mapping drops Partitioned. */ +final class PartitionedCsrfTokenRepository implements CsrfTokenRepository { + private final CsrfTokenRepository delegate; + + PartitionedCsrfTokenRepository(CsrfTokenRepository delegate) { + this.delegate = delegate; + } + + @Override + public CsrfToken generateToken(HttpServletRequest request) { + return delegate.generateToken(request); + } + + @Override + public CsrfToken loadToken(HttpServletRequest request) { + return delegate.loadToken(request); + } + + @Override + public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { + delegate.saveToken(token, request, new HttpServletResponseWrapper(response) { + @Override + public void addCookie(Cookie cookie) { + ResponseCookie partitioned = ResponseCookie.from(cookie.getName(), cookie.getValue()) + .path(cookie.getPath()) + .domain(cookie.getDomain()) + .secure(cookie.getSecure()) + .httpOnly(cookie.isHttpOnly()) + .maxAge(cookie.getMaxAge()) + .sameSite(cookie.getAttribute("SameSite")) + .partitioned(true) + .build(); + super.addHeader(HttpHeaders.SET_COOKIE, partitioned.toString()); + } + }); + } +} diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 9b2ae8cfc..b8ba7bfb5 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -48,6 +48,7 @@ import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRepository; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; import org.springframework.web.cors.CorsConfigurationSource; @@ -119,7 +120,12 @@ private boolean isPreviewEnvironment() { String cleanUrl = getCleanFrontendUrl(); if (cleanUrl == null || cleanUrl.isBlank()) return false; String host = safeHostFromUrl(cleanUrl); - return (host != null && host.endsWith(".run.app")) || "dev.modtale.net".equalsIgnoreCase(host); + return isCloudRunPreviewEnvironment() || "dev.modtale.net".equalsIgnoreCase(host); + } + + private boolean isCloudRunPreviewEnvironment() { + String host = safeHostFromUrl(getCleanFrontendUrl()); + return host != null && host.toLowerCase(java.util.Locale.ROOT).endsWith(".run.app"); } private boolean isLocalhost() { @@ -182,6 +188,9 @@ public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setUseSecureCookie(!isLocalhost()); serializer.setCookiePath("/"); + // Cloud Run frontend and API hosts are cross-site. Keep their session scoped + // to the frontend's cookie partition when third-party cookies are blocked. + serializer.setPartitioned(isCloudRunPreviewEnvironment()); boolean isPreview = isPreviewEnvironment(); String cleanUrl = getCleanFrontendUrl(); @@ -206,10 +215,7 @@ public CookieSerializer cookieSerializer() { } @Bean - public SecurityFilterChain securityFilterChain( - HttpSecurity http, - OAuth2AuthorizationRequestResolver authorizationRequestResolver - ) throws Exception { + public CsrfTokenRepository csrfTokenRepository() { CookieCsrfTokenRepository tokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); tokenRepository.setCookiePath("/"); @@ -217,6 +223,7 @@ public SecurityFilterChain securityFilterChain( boolean isPreview = isPreviewEnvironment(); String cleanUrl = getCleanFrontendUrl(); cookie.secure(!isLocalhost()); + cookie.partitioned(isCloudRunPreviewEnvironment()); if (isPreview) { cookie.sameSite("None"); @@ -236,6 +243,16 @@ public SecurityFilterChain securityFilterChain( } }); + return isCloudRunPreviewEnvironment() ? new PartitionedCsrfTokenRepository(tokenRepository) : tokenRepository; + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + OAuth2AuthorizationRequestResolver authorizationRequestResolver + ) throws Exception { + CsrfTokenRepository tokenRepository = csrfTokenRepository(); + PublicReadCsrfTokenRequestHandler requestHandler = new PublicReadCsrfTokenRequestHandler(); http @@ -289,7 +306,10 @@ public SecurityFilterChain securityFilterChain( .requestMatchers("/sitemap.xml", "/actuator/health", "/actuator/health/**").permitAll() .requestMatchers("/client-metadata.json").permitAll() .requestMatchers(HttpMethod.GET, + "/api/v1/projects", "/api/v1/projects/**", + "/api/v1/modjams", + "/api/v1/modjams/**", "/api/v1/news", "/api/v1/news/**", "/api/v1/tags", @@ -359,9 +379,10 @@ public SecurityFilterChain securityFilterChain( "/api/v1/auth/change-password", "/api/v1/auth/credentials" ).authenticated() - .requestMatchers(HttpMethod.POST, "/api/v1/projects/**").authenticated() - .requestMatchers(HttpMethod.PUT, "/api/v1/projects/**").authenticated() - .requestMatchers(HttpMethod.DELETE, "/api/v1/projects/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v1/projects", "/api/v1/projects/**", "/api/v1/modjams", "/api/v1/modjams/**").authenticated() + .requestMatchers(HttpMethod.PUT, "/api/v1/projects", "/api/v1/projects/**", "/api/v1/modjams", "/api/v1/modjams/**").authenticated() + .requestMatchers(HttpMethod.DELETE, "/api/v1/projects", "/api/v1/projects/**", "/api/v1/modjams", "/api/v1/modjams/**").authenticated() + .requestMatchers(HttpMethod.PATCH, "/api/v1/modjams", "/api/v1/modjams/**").authenticated() .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() ) diff --git a/backend/src/main/java/net/modtale/controller/ModjamController.java b/backend/src/main/java/net/modtale/controller/ModjamController.java new file mode 100644 index 000000000..c713e280b --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/ModjamController.java @@ -0,0 +1,223 @@ +package net.modtale.controller; + +import jakarta.validation.Valid; +import net.modtale.model.dto.request.jam.VoteRequest; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamSubmission; +import net.modtale.model.user.User; +import net.modtale.exception.ApiKeyOperationForbiddenException; +import net.modtale.service.ModjamService; +import net.modtale.service.jam.ModjamOrganizerService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.user.account.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/modjams") +public class ModjamController { + + @Autowired private ModjamService modjamService; + @Autowired private ModjamOrganizerService organizerService; + @Autowired private AccountService accountService; + @Autowired private AccessControlService accessControlService; + + private User getBrowserUser() { + if (accessControlService.isApiKey(SecurityContextHolder.getContext().getAuthentication())) { + throw new ApiKeyOperationForbiddenException("API keys cannot be used to manage or participate in modjams."); + } + return accountService.getCurrentUser(); + } + + @GetMapping + public ResponseEntity> getAllJams() { + return ResponseEntity.ok(modjamService.getAllJams()); + } + + @GetMapping("/user/me") + public ResponseEntity> getMyJams() { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.getUserHostedJams(user.getId())); + } + + @GetMapping("/{slug}") + public ResponseEntity getJamBySlug(@PathVariable String slug) { + return ResponseEntity.ok(modjamService.getJamBySlug(slug)); + } + + @PostMapping + public ResponseEntity createJam(@RequestBody Modjam jam) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.createJam(jam, user.getId(), user.getUsername())); + } + + @PutMapping("/{id}") + public ResponseEntity updateJam(@PathVariable String id, @RequestBody Modjam jam) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(modjamService.updateJam(id, jam, user.getId()), user)); + } + + @PutMapping("/{id}/icon") + public ResponseEntity updateIcon(@PathVariable String id, @RequestParam("file") MultipartFile file) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + try { + modjamService.updateIcon(id, file, user.getId()); + return ResponseEntity.ok().build(); + } catch (ResponseStatusException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getReason()); + } catch (Exception e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/{id}/banner") + public ResponseEntity updateBanner(@PathVariable String id, @RequestParam("file") MultipartFile file) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + try { + modjamService.updateBanner(id, file, user.getId()); + return ResponseEntity.ok().build(); + } catch (ResponseStatusException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getReason()); + } catch (Exception e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteJam(@PathVariable String id) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + try { + modjamService.deleteJam(id, user.getId()); + return ResponseEntity.ok().build(); + } catch (ResponseStatusException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getReason()); + } catch (Exception e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/{jamId}/submissions") + public ResponseEntity> getSubmissions(@PathVariable String jamId) { + return ResponseEntity.ok(modjamService.getSubmissions(jamId)); + } + + @PostMapping("/{jamId}/participate") + public ResponseEntity participate(@PathVariable String jamId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.participate(jamId, user.getId())); + } + + @PostMapping("/{jamId}/leave") + public ResponseEntity leaveJam(@PathVariable String jamId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.leaveJam(jamId, user.getId())); + } + + @PostMapping("/{jamId}/submit") + public ResponseEntity submitProject(@PathVariable String jamId, @RequestBody Map body) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.submitProject(jamId, body.get("projectId"), user.getId())); + } + + @PostMapping("/{jamId}/vote") + public ResponseEntity vote(@PathVariable String jamId, @Valid @RequestBody VoteRequest body) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + + return ResponseEntity.ok(modjamService.vote(jamId, body.submissionId(), body.categoryId(), body.score().intValueExact(), user.getId())); + } + + @PostMapping("/{jamId}/finalize") + public ResponseEntity finalizeJam(@PathVariable String jamId, @RequestBody List> winners) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(modjamService.finalizeJam(jamId, user.getId(), winners), user)); + } + + // Judging Endpoints + @PostMapping("/{jamId}/judges/invite") + public ResponseEntity inviteJudge(@PathVariable String jamId, @RequestBody Map body) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.inviteJudge(jamId, body.get("username"), user.getId())); + } + + @PostMapping("/{jamId}/judges/accept") + public ResponseEntity acceptJudge(@PathVariable String jamId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.acceptJudgeInvite(jamId, user.getId(), user.getUsername())); + } + + @PostMapping("/{jamId}/judges/decline") + public ResponseEntity declineJudge(@PathVariable String jamId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.declineJudgeInvite(jamId, user.getId(), user.getUsername())); + } + + @DeleteMapping("/{jamId}/judges/{username}") + public ResponseEntity removeJudge(@PathVariable String jamId, @PathVariable String username) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.removeJudge(jamId, username, user.getId())); + } + + @PostMapping("/{jamId}/organizer-roles") + public ResponseEntity saveOrganizerRole(@PathVariable String jamId, @RequestBody Modjam.OrganizerRole role) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(organizerService.saveRole(jamId, user.getId(), role), user)); + } + + @DeleteMapping("/{jamId}/organizer-roles/{roleId}") + public ResponseEntity deleteOrganizerRole(@PathVariable String jamId, @PathVariable String roleId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(organizerService.deleteRole(jamId, user.getId(), roleId), user)); + } + + @PostMapping("/{jamId}/organizers/invite") + public ResponseEntity inviteOrganizer(@PathVariable String jamId, @RequestBody Map input) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(organizerService.invite(jamId, user.getId(), input.get("username"), input.get("roleId")), user)); + } + + @PostMapping("/{jamId}/organizers/accept") + public ResponseEntity acceptOrganizer(@PathVariable String jamId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(organizerService.answerInvite(jamId, user.getId(), true), user)); + } + + @PostMapping("/{jamId}/organizers/decline") + public ResponseEntity declineOrganizer(@PathVariable String jamId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(organizerService.answerInvite(jamId, user.getId(), false), user)); + } + + @DeleteMapping("/{jamId}/organizers/{userId}") + public ResponseEntity removeOrganizer(@PathVariable String jamId, @PathVariable String userId) { + User user = getBrowserUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(modjamService.jamForViewer(organizerService.remove(jamId, user.getId(), userId), user)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/auth/AuthController.java b/backend/src/main/java/net/modtale/controller/auth/AuthController.java index 9c491852d..27d0f264d 100644 --- a/backend/src/main/java/net/modtale/controller/auth/AuthController.java +++ b/backend/src/main/java/net/modtale/controller/auth/AuthController.java @@ -42,6 +42,7 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.csrf.CsrfTokenRepository; import org.springframework.web.bind.annotation.*; @RestController @@ -57,6 +58,7 @@ public class AuthController { private final net.modtale.service.auth.MfaEnrollmentService mfaEnrollmentService; private final LauncherAuthService launcherAuthService; private final SecurityContextRepository securityContextRepository; + private final CsrfTokenRepository csrfTokenRepository; public AuthController( AuthenticationService authenticationService, @@ -65,7 +67,8 @@ public AuthController( TwoFactorService twoFactorService, LauncherAuthService launcherAuthService, SecurityContextRepository securityContextRepository, - net.modtale.service.auth.MfaEnrollmentService mfaEnrollmentService + net.modtale.service.auth.MfaEnrollmentService mfaEnrollmentService, + CsrfTokenRepository csrfTokenRepository ) { this.authenticationService = authenticationService; this.mfaEnrollmentService = mfaEnrollmentService; @@ -74,6 +77,7 @@ public AuthController( this.twoFactorService = twoFactorService; this.launcherAuthService = launcherAuthService; this.securityContextRepository = securityContextRepository; + this.csrfTokenRepository = csrfTokenRepository; } @PostMapping("/register") @@ -118,7 +122,8 @@ public ResponseEntity logout(HttpServletRequest request, HttpSer expireCookie(response, "SESSION"); expireCookie(response, "JSESSIONID"); - expireCookie(response, "XSRF-TOKEN"); + // Delete the same domain/partition as the issued CSRF cookie. + csrfTokenRepository.saveToken(null, request, response); return ResponseEntity.ok(new StatusResponse("success")); } diff --git a/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java b/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java index aaafc4532..dbc7dfcdb 100644 --- a/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java @@ -5,18 +5,32 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.server.ResponseStatusException; @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatus(ResponseStatusException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail(ex.getStatusCode(), + ex.getReason() == null ? "The request could not be processed." : ex.getReason()); + return ResponseEntity.status(ex.getStatusCode()).body(problem); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadableBody(HttpMessageNotReadableException ex) { + return ErrorMessageUtils.badRequest("The request body must contain valid JSON with the expected field types."); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleBadRequests(IllegalArgumentException ex) { logger.error("IllegalArgumentException:", ex); diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java index f4ebdbc45..0a67cb561 100644 --- a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java @@ -35,6 +35,7 @@ public class ProjectDTO { private Map links; private List types; private List childProjectIds; + private List modjamIds; private boolean allowModpacks; private boolean allowComments; private boolean hmWikiEnabled; @@ -106,6 +107,8 @@ public class ProjectDTO { public void setTypes(List types) { this.types = types; } public List getChildProjectIds() { return childProjectIds; } public void setChildProjectIds(List childProjectIds) { this.childProjectIds = childProjectIds; } + public List getModjamIds() { return modjamIds; } + public void setModjamIds(List modjamIds) { this.modjamIds = modjamIds; } public boolean isAllowModpacks() { return allowModpacks; } public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } diff --git a/backend/src/main/java/net/modtale/model/dto/request/jam/VoteRequest.java b/backend/src/main/java/net/modtale/model/dto/request/jam/VoteRequest.java new file mode 100644 index 000000000..7e7310458 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/jam/VoteRequest.java @@ -0,0 +1,14 @@ +package net.modtale.model.dto.request.jam; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; + +public record VoteRequest( + @NotBlank String submissionId, + @NotBlank String categoryId, + @NotNull @DecimalMin("1") @Digits(integer = 9, fraction = 0) BigDecimal score +) { +} diff --git a/backend/src/main/java/net/modtale/model/jam/Modjam.java b/backend/src/main/java/net/modtale/model/jam/Modjam.java new file mode 100644 index 000000000..63d8be40a --- /dev/null +++ b/backend/src/main/java/net/modtale/model/jam/Modjam.java @@ -0,0 +1,216 @@ +package net.modtale.model.jam; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.LinkedHashMap; + +@Document(collection = "modjams") +public class Modjam { + + @Id + private String id; + private String slug; + private String title; + private String description; + private String rules; + private String customCss; + + private String imageUrl; + private String bannerUrl; + + private String hostId; + private String hostName; + + private Instant startDate; + private Instant endDate; + private Instant votingEndDate; + + private String status = "DRAFT"; + + private List participantIds = new ArrayList<>(); + private List judgeIds = new ArrayList<>(); + private List pendingJudgeInvites = new ArrayList<>(); + private Map pendingJudgeInviteUsers = new LinkedHashMap<>(); + private List organizerRoles = new ArrayList<>(); + private List organizerMembers = new ArrayList<>(); + private List pendingOrganizerInvites = new ArrayList<>(); + + public enum JamPermission { + EDIT_DETAILS, EDIT_RULES, MANAGE_SETTINGS, MANAGE_JUDGES, VIEW_RESULTS, ANNOUNCE_WINNERS + } + + public record OrganizerRole(String id, String name, String color, Set permissions) {} + public record OrganizerMember(String userId, String roleId) {} + public record OrganizerInvite(String userId, String username, String roleId) {} + + @Transient + private List> judgeProfiles = new ArrayList<>(); + + public static class Category { + private String id; + private String name; + private String description; + private int maxScore; + + public Category() {} + + public Category(String id, String name, String description, int maxScore) { + this.id = id; + this.name = name; + this.description = description; + this.maxScore = maxScore; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public int getMaxScore() { return maxScore; } + public void setMaxScore(int maxScore) { this.maxScore = maxScore; } + } + + public static class Restrictions { + private boolean requireNewProject; + private boolean requireSourceRepo; + private boolean requireOsiLicense; + private Integer minContributors; + private Integer maxContributors; + private boolean requireUniqueSubmission; + private boolean requireNewbie; + private boolean requirePriorJams; + private boolean requireNoPriorProjects; + private boolean requirePriorProjects; + private List allowedLicenses = new ArrayList<>(); + private List allowedClassifications = new ArrayList<>(); + private List allowedGameVersions = new ArrayList<>(); + private String minimumGameVersion; + private String maximumGameVersion; + private String requiredDependencyId; + private String requiredClassUsage; + + public boolean isRequireNewProject() { return requireNewProject; } + public void setRequireNewProject(boolean requireNewProject) { this.requireNewProject = requireNewProject; } + public boolean isRequireSourceRepo() { return requireSourceRepo; } + public void setRequireSourceRepo(boolean requireSourceRepo) { this.requireSourceRepo = requireSourceRepo; } + public boolean isRequireOsiLicense() { return requireOsiLicense; } + public void setRequireOsiLicense(boolean requireOsiLicense) { this.requireOsiLicense = requireOsiLicense; } + public Integer getMinContributors() { return minContributors; } + public void setMinContributors(Integer minContributors) { this.minContributors = minContributors; } + public Integer getMaxContributors() { return maxContributors; } + public void setMaxContributors(Integer maxContributors) { this.maxContributors = maxContributors; } + public boolean isRequireUniqueSubmission() { return requireUniqueSubmission; } + public void setRequireUniqueSubmission(boolean requireUniqueSubmission) { this.requireUniqueSubmission = requireUniqueSubmission; } + public boolean isRequireNewbie() { return requireNewbie; } + public void setRequireNewbie(boolean requireNewbie) { this.requireNewbie = requireNewbie; } + public boolean isRequirePriorJams() { return requirePriorJams; } + public void setRequirePriorJams(boolean requirePriorJams) { this.requirePriorJams = requirePriorJams; } + public boolean isRequireNoPriorProjects() { return requireNoPriorProjects; } + public void setRequireNoPriorProjects(boolean requireNoPriorProjects) { this.requireNoPriorProjects = requireNoPriorProjects; } + public boolean isRequirePriorProjects() { return requirePriorProjects; } + public void setRequirePriorProjects(boolean requirePriorProjects) { this.requirePriorProjects = requirePriorProjects; } + public List getAllowedLicenses() { return allowedLicenses; } + public void setAllowedLicenses(List allowedLicenses) { this.allowedLicenses = allowedLicenses; } + public List getAllowedClassifications() { return allowedClassifications; } + public void setAllowedClassifications(List allowedClassifications) { this.allowedClassifications = allowedClassifications; } + public List getAllowedGameVersions() { return allowedGameVersions; } + public void setAllowedGameVersions(List allowedGameVersions) { this.allowedGameVersions = allowedGameVersions; } + public String getMinimumGameVersion() { return minimumGameVersion; } + public void setMinimumGameVersion(String value) { minimumGameVersion = value; } + public String getMaximumGameVersion() { return maximumGameVersion; } + public void setMaximumGameVersion(String value) { maximumGameVersion = value; } + public String getRequiredDependencyId() { return requiredDependencyId; } + public void setRequiredDependencyId(String requiredDependencyId) { this.requiredDependencyId = requiredDependencyId; } + public String getRequiredClassUsage() { return requiredClassUsage; } + public void setRequiredClassUsage(String requiredClassUsage) { this.requiredClassUsage = requiredClassUsage; } + } + + private List categories = new ArrayList<>(); + private Restrictions restrictions = new Restrictions(); + + private boolean allowPublicVoting; + private boolean allowConcurrentVoting; + private boolean showResultsBeforeVotingEnds; + private boolean oneEntryPerPerson = true; + private boolean hideSubmissions; + + private Instant publishedAt; + private Instant winnersAnnouncedAt; + private Instant createdAt = Instant.now(); + private Instant updatedAt = Instant.now(); + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getRules() { return rules; } + public void setRules(String rules) { this.rules = rules; } + public String getCustomCss() { return customCss; } + public void setCustomCss(String value) { customCss = value; } + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + public String getBannerUrl() { return bannerUrl; } + public void setBannerUrl(String bannerUrl) { this.bannerUrl = bannerUrl; } + public String getHostId() { return hostId; } + public void setHostId(String hostId) { this.hostId = hostId; } + public String getHostName() { return hostName; } + public void setHostName(String hostName) { this.hostName = hostName; } + public Instant getStartDate() { return startDate; } + public void setStartDate(Instant startDate) { this.startDate = startDate; } + public Instant getEndDate() { return endDate; } + public void setEndDate(Instant endDate) { this.endDate = endDate; } + public Instant getVotingEndDate() { return votingEndDate; } + public void setVotingEndDate(Instant votingEndDate) { this.votingEndDate = votingEndDate; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public List getParticipantIds() { return participantIds; } + public void setParticipantIds(List participantIds) { this.participantIds = participantIds; } + public List getJudgeIds() { return judgeIds; } + public void setJudgeIds(List judgeIds) { this.judgeIds = judgeIds; } + public List getPendingJudgeInvites() { return pendingJudgeInvites; } + public void setPendingJudgeInvites(List pendingJudgeInvites) { this.pendingJudgeInvites = pendingJudgeInvites; } + public Map getPendingJudgeInviteUsers() { return pendingJudgeInviteUsers; } + public void setPendingJudgeInviteUsers(Map values) { pendingJudgeInviteUsers = values; } + public List getOrganizerRoles() { return organizerRoles; } + public void setOrganizerRoles(List values) { organizerRoles = values; } + public List getOrganizerMembers() { return organizerMembers; } + public void setOrganizerMembers(List values) { organizerMembers = values; } + public List getPendingOrganizerInvites() { return pendingOrganizerInvites; } + public void setPendingOrganizerInvites(List values) { pendingOrganizerInvites = values; } + public List> getJudgeProfiles() { return judgeProfiles; } + public void setJudgeProfiles(List> judgeProfiles) { this.judgeProfiles = judgeProfiles; } + public List getCategories() { return categories; } + public void setCategories(List categories) { this.categories = categories; } + public Restrictions getRestrictions() { return restrictions; } + public void setRestrictions(Restrictions restrictions) { this.restrictions = restrictions; } + public boolean isAllowPublicVoting() { return allowPublicVoting; } + public void setAllowPublicVoting(boolean allowPublicVoting) { this.allowPublicVoting = allowPublicVoting; } + public boolean isAllowConcurrentVoting() { return allowConcurrentVoting; } + public void setAllowConcurrentVoting(boolean allowConcurrentVoting) { this.allowConcurrentVoting = allowConcurrentVoting; } + public boolean isShowResultsBeforeVotingEnds() { return showResultsBeforeVotingEnds; } + public void setShowResultsBeforeVotingEnds(boolean showResultsBeforeVotingEnds) { this.showResultsBeforeVotingEnds = showResultsBeforeVotingEnds; } + public boolean isOneEntryPerPerson() { return oneEntryPerPerson; } + public void setOneEntryPerPerson(boolean oneEntryPerPerson) { this.oneEntryPerPerson = oneEntryPerPerson; } + public boolean isHideSubmissions() { return hideSubmissions; } + public void setHideSubmissions(boolean hideSubmissions) { this.hideSubmissions = hideSubmissions; } + public Instant getWinnersAnnouncedAt() { return winnersAnnouncedAt; } + public void setWinnersAnnouncedAt(Instant value) { winnersAnnouncedAt = value; } + public Instant getPublishedAt() { return publishedAt; } + public void setPublishedAt(Instant value) { publishedAt = value; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/net/modtale/model/jam/ModjamDiscordEvent.java b/backend/src/main/java/net/modtale/model/jam/ModjamDiscordEvent.java new file mode 100644 index 000000000..e44d801f2 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/jam/ModjamDiscordEvent.java @@ -0,0 +1,59 @@ +package net.modtale.model.jam; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.mapping.Document; + +/** A durable milestone, with no webhook credentials persisted in the document. */ +@Document(collection = "modjam_discord_outbox") +@CompoundIndex(name = "modjam_feed_due", def = "{'state': 1, 'nextAttemptAt': 1, 'leaseUntil': 1}") +public class ModjamDiscordEvent { + public enum Milestone { CREATED, SUBMISSIONS_OPENED, VOTING_OPENED, WINNERS_ANNOUNCED } + public enum State { PENDING, DELIVERED, DISCARDED } + + @Id private String id; + private String jamId; + private Milestone milestone; + private Instant occurredAt; + private Instant nextAttemptAt; + private Instant leaseUntil; + private String leaseToken; + private State state = State.PENDING; + private int attempts; + private Instant deliveredAt; + private Integer lastStatus; + + public ModjamDiscordEvent() {} + + public ModjamDiscordEvent(String jamId, Milestone milestone, Instant occurredAt, Instant now) { + this.id = "modjam:" + jamId + ":" + milestone.name(); + this.jamId = jamId; + this.milestone = milestone; + this.occurredAt = occurredAt; + this.nextAttemptAt = now; + } + + public String getId() { return id; } + public void setId(String value) { id = value; } + public String getJamId() { return jamId; } + public void setJamId(String value) { jamId = value; } + public Milestone getMilestone() { return milestone; } + public void setMilestone(Milestone value) { milestone = value; } + public Instant getOccurredAt() { return occurredAt; } + public void setOccurredAt(Instant value) { occurredAt = value; } + public Instant getNextAttemptAt() { return nextAttemptAt; } + public void setNextAttemptAt(Instant value) { nextAttemptAt = value; } + public Instant getLeaseUntil() { return leaseUntil; } + public void setLeaseUntil(Instant value) { leaseUntil = value; } + public String getLeaseToken() { return leaseToken; } + public void setLeaseToken(String value) { leaseToken = value; } + public State getState() { return state; } + public void setState(State value) { state = value; } + public int getAttempts() { return attempts; } + public void setAttempts(int value) { attempts = value; } + public Instant getDeliveredAt() { return deliveredAt; } + public void setDeliveredAt(Instant value) { deliveredAt = value; } + public Integer getLastStatus() { return lastStatus; } + public void setLastStatus(Integer value) { lastStatus = value; } +} diff --git a/backend/src/main/java/net/modtale/model/jam/ModjamDiscordFeedBackoff.java b/backend/src/main/java/net/modtale/model/jam/ModjamDiscordFeedBackoff.java new file mode 100644 index 000000000..ad60e4d66 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/jam/ModjamDiscordFeedBackoff.java @@ -0,0 +1,8 @@ +package net.modtale.model.jam; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "modjam_discord_feed_backoff") +public record ModjamDiscordFeedBackoff(@Id String id, Instant blockedUntil, Instant activatedAt) {} diff --git a/backend/src/main/java/net/modtale/model/jam/ModjamSubmission.java b/backend/src/main/java/net/modtale/model/jam/ModjamSubmission.java new file mode 100644 index 000000000..cb5f259c9 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/jam/ModjamSubmission.java @@ -0,0 +1,123 @@ +package net.modtale.model.jam; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Transient; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Document(collection = "modjam_submissions") +public class ModjamSubmission { + + @Id + private String id; + private String jamId; + private String projectId; + + private String projectTitle; + private String projectImageUrl; + private String projectBannerUrl; + private String projectAuthor; + private String projectDescription; + + private String submitterId; + private List votes = new ArrayList<>(); + private long voteRevision; + + private Map categoryScores; + private Double totalScore; + + private Map judgeCategoryScores; + private Double totalJudgeScore; + private Double totalPublicScore; + + private Integer rank; + + private boolean isWinner; + private String awardTitle; + + private Instant createdAt = Instant.now(); + + @Transient + private Integer votesCast; + + @Transient + private Integer commentsGiven; + + public static class Vote { + private String id; + private String voterId; + private String categoryId; + private int score; + private boolean isJudge; + + public Vote() {} + + public Vote(String id, String voterId, String categoryId, int score, boolean isJudge) { + this.id = id; + this.voterId = voterId; + this.categoryId = categoryId; + this.score = score; + this.isJudge = isJudge; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getVoterId() { return voterId; } + public void setVoterId(String voterId) { this.voterId = voterId; } + public String getCategoryId() { return categoryId; } + public void setCategoryId(String categoryId) { this.categoryId = categoryId; } + public int getScore() { return score; } + public void setScore(int score) { this.score = score; } + public boolean isJudge() { return isJudge; } + public void setJudge(boolean judge) { isJudge = judge; } + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getJamId() { return jamId; } + public void setJamId(String jamId) { this.jamId = jamId; } + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + public String getProjectTitle() { return projectTitle; } + public void setProjectTitle(String projectTitle) { this.projectTitle = projectTitle; } + public String getProjectImageUrl() { return projectImageUrl; } + public void setProjectImageUrl(String projectImageUrl) { this.projectImageUrl = projectImageUrl; } + public String getProjectBannerUrl() { return projectBannerUrl; } + public void setProjectBannerUrl(String projectBannerUrl) { this.projectBannerUrl = projectBannerUrl; } + public String getProjectAuthor() { return projectAuthor; } + public void setProjectAuthor(String projectAuthor) { this.projectAuthor = projectAuthor; } + public String getProjectDescription() { return projectDescription; } + public void setProjectDescription(String projectDescription) { this.projectDescription = projectDescription; } + public String getSubmitterId() { return submitterId; } + public void setSubmitterId(String submitterId) { this.submitterId = submitterId; } + public List getVotes() { return votes; } + public void setVotes(List votes) { this.votes = votes; } + public long getVoteRevision() { return voteRevision; } + public void setVoteRevision(long voteRevision) { this.voteRevision = voteRevision; } + public Map getCategoryScores() { return categoryScores; } + public void setCategoryScores(Map categoryScores) { this.categoryScores = categoryScores; } + public Double getTotalScore() { return totalScore; } + public void setTotalScore(Double totalScore) { this.totalScore = totalScore; } + public Map getJudgeCategoryScores() { return judgeCategoryScores; } + public void setJudgeCategoryScores(Map judgeCategoryScores) { this.judgeCategoryScores = judgeCategoryScores; } + public Double getTotalJudgeScore() { return totalJudgeScore; } + public void setTotalJudgeScore(Double totalJudgeScore) { this.totalJudgeScore = totalJudgeScore; } + public Double getTotalPublicScore() { return totalPublicScore; } + public void setTotalPublicScore(Double totalPublicScore) { this.totalPublicScore = totalPublicScore; } + public Integer getRank() { return rank; } + public void setRank(Integer rank) { this.rank = rank; } + public boolean isWinner() { return isWinner; } + public void setWinner(boolean winner) { this.isWinner = winner; } + public String getAwardTitle() { return awardTitle; } + public void setAwardTitle(String awardTitle) { this.awardTitle = awardTitle; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Integer getVotesCast() { return votesCast; } + public void setVotesCast(Integer votesCast) { this.votesCast = votesCast; } + public Integer getCommentsGiven() { return commentsGiven; } + public void setCommentsGiven(Integer commentsGiven) { this.commentsGiven = commentsGiven; } +} diff --git a/backend/src/main/java/net/modtale/model/project/Project.java b/backend/src/main/java/net/modtale/model/project/Project.java index b5511bb0c..4a8f61d57 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -185,6 +185,10 @@ public ProjectMember(String userId, String roleId) { private List types; private List childProjectIds; + + private List modjamIds = new ArrayList<>(); + private boolean modjamPublicationPending; + private boolean allowModpacks = true; private boolean allowComments = true; @@ -286,6 +290,12 @@ public Project() {} public void setTypes(List types) { this.types = types; } public List getChildProjectIds() { return childProjectIds; } public void setChildProjectIds(List childProjectIds) { this.childProjectIds = childProjectIds; } + + public List getModjamIds() { return modjamIds; } + public void setModjamIds(List modjamIds) { this.modjamIds = modjamIds; } + public boolean isModjamPublicationPending() { return modjamPublicationPending; } + public void setModjamPublicationPending(boolean modjamPublicationPending) { this.modjamPublicationPending = modjamPublicationPending; } + public boolean isAllowModpacks() { return allowModpacks; } public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } diff --git a/backend/src/main/java/net/modtale/model/user/User.java b/backend/src/main/java/net/modtale/model/user/User.java index 3b22a8f31..48a7ca68a 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -70,6 +70,8 @@ public class User implements Serializable { private List followingIds = new ArrayList<>(); private List followerIds = new ArrayList<>(); + private List joinedModjamIds = new ArrayList<>(); + private List connectedAccounts = new ArrayList<>(); private List badges = new ArrayList<>(); @@ -327,6 +329,9 @@ public void setAdminPermissions(Set adminPermissions) { public List getFollowerIds() { return followerIds; } public void setFollowerIds(List followerIds) { this.followerIds = followerIds; } + public List getJoinedModjamIds() { return joinedModjamIds; } + public void setJoinedModjamIds(List joinedModjamIds) { this.joinedModjamIds = joinedModjamIds; } + public List getConnectedAccounts() { return connectedAccounts; } public void setConnectedAccounts(List connectedAccounts) { this.connectedAccounts = connectedAccounts; } diff --git a/backend/src/main/java/net/modtale/repository/jam/ModjamDiscordOutboxStore.java b/backend/src/main/java/net/modtale/repository/jam/ModjamDiscordOutboxStore.java new file mode 100644 index 000000000..ef00efbd5 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/jam/ModjamDiscordOutboxStore.java @@ -0,0 +1,16 @@ +package net.modtale.repository.jam; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import net.modtale.model.jam.ModjamDiscordEvent; + +public interface ModjamDiscordOutboxStore { + void enqueue(ModjamDiscordEvent event); + Optional claimNext(Instant now, Duration lease); + void delivered(ModjamDiscordEvent event, Instant now); + void retry(ModjamDiscordEvent event, Instant nextAttempt, Integer status); + void discard(ModjamDiscordEvent event); + void pauseUntil(Instant until); + Instant activationTime(Instant now); +} diff --git a/backend/src/main/java/net/modtale/repository/jam/ModjamRepository.java b/backend/src/main/java/net/modtale/repository/jam/ModjamRepository.java new file mode 100644 index 000000000..0f074cb29 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/jam/ModjamRepository.java @@ -0,0 +1,17 @@ +package net.modtale.repository.jam; + +import net.modtale.model.jam.Modjam; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ModjamRepository extends MongoRepository { + Optional findBySlug(String slug); + List findByStatusIn(List statuses); + List findByHostId(String hostId); + List findByStatusAndUpdatedAtBefore(String status, Instant date); +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/repository/jam/ModjamSubmissionRepository.java b/backend/src/main/java/net/modtale/repository/jam/ModjamSubmissionRepository.java new file mode 100644 index 000000000..f00565d00 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/jam/ModjamSubmissionRepository.java @@ -0,0 +1,14 @@ +package net.modtale.repository.jam; + +import net.modtale.model.jam.ModjamSubmission; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ModjamSubmissionRepository extends MongoRepository { + List findByJamId(String jamId); + List findByJamIdAndSubmitterId(String jamId, String submitterId); + List findByProjectId(String projectId); +} \ No newline at end of file diff --git a/backend/src/main/java/net/modtale/repository/jam/MongoModjamDiscordOutboxStore.java b/backend/src/main/java/net/modtale/repository/jam/MongoModjamDiscordOutboxStore.java new file mode 100644 index 000000000..69c0cef4d --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/jam/MongoModjamDiscordOutboxStore.java @@ -0,0 +1,100 @@ +package net.modtale.repository.jam; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import net.modtale.model.jam.ModjamDiscordEvent; +import net.modtale.model.jam.ModjamDiscordEvent.State; +import net.modtale.model.jam.ModjamDiscordFeedBackoff; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class MongoModjamDiscordOutboxStore implements ModjamDiscordOutboxStore { + private final MongoTemplate mongo; + + public MongoModjamDiscordOutboxStore(MongoTemplate mongo) { this.mongo = mongo; } + + @Override + public void enqueue(ModjamDiscordEvent event) { + try { + // Mongo's built-in unique _id index deduplicates across processes and restarts. + mongo.insert(event); + } catch (DuplicateKeyException ignored) { + // Never overwrite a delivered event or reset a pending event's retries. + } + } + + @Override + public Optional claimNext(Instant now, Duration lease) { + ModjamDiscordFeedBackoff backoff = mongo.findById("modjam-feed", ModjamDiscordFeedBackoff.class); + if (backoff != null && backoff.blockedUntil() != null && now.isBefore(backoff.blockedUntil())) { + return Optional.empty(); + } + Criteria criteria = new Criteria().andOperator( + Criteria.where("state").is(State.PENDING), + Criteria.where("nextAttemptAt").lte(now), + new Criteria().orOperator(Criteria.where("leaseUntil").is(null), + Criteria.where("leaseUntil").lte(now))); + Query query = new Query(criteria).with(Sort.by("occurredAt", "_id")); + Update update = new Update().set("leaseUntil", now.plus(lease)) + .set("leaseToken", UUID.randomUUID().toString()).inc("attempts", 1); + return Optional.ofNullable(mongo.findAndModify(query, update, + FindAndModifyOptions.options().returnNew(true), ModjamDiscordEvent.class)); + } + + @Override + public void delivered(ModjamDiscordEvent event, Instant now) { + mongo.updateFirst(owned(event), clearLease().set("state", State.DELIVERED) + .set("deliveredAt", now).unset("lastStatus"), ModjamDiscordEvent.class); + } + + @Override + public void retry(ModjamDiscordEvent event, Instant nextAttempt, Integer status) { + mongo.updateFirst(owned(event), clearLease().set("nextAttemptAt", nextAttempt) + .set("lastStatus", status), ModjamDiscordEvent.class); + } + + @Override + public void discard(ModjamDiscordEvent event) { + mongo.updateFirst(owned(event), clearLease().set("state", State.DISCARDED), ModjamDiscordEvent.class); + } + + @Override + public void pauseUntil(Instant until) { + mongo.upsert(new Query(Criteria.where("_id").is("modjam-feed")), + new Update().max("blockedUntil", until), ModjamDiscordFeedBackoff.class); + } + + @Override + public Instant activationTime(Instant now) { + ModjamDiscordFeedBackoff state = mongo.findById("modjam-feed", ModjamDiscordFeedBackoff.class); + if (state != null && state.activatedAt() != null) return state.activatedAt(); + try { + // The conditional unique-ID upsert chooses one first activation across processes. + state = mongo.findAndModify(new Query(Criteria.where("_id").is("modjam-feed") + .and("activatedAt").is(null)), new Update().set("activatedAt", now), + FindAndModifyOptions.options().upsert(true).returnNew(true), ModjamDiscordFeedBackoff.class); + if (state == null || state.activatedAt() == null) throw new IllegalStateException("Feed activation unavailable"); + return state.activatedAt(); + } catch (DuplicateKeyException concurrentActivation) { + state = mongo.findById("modjam-feed", ModjamDiscordFeedBackoff.class); + if (state == null || state.activatedAt() == null) throw concurrentActivation; + return state.activatedAt(); + } + } + + private Query owned(ModjamDiscordEvent event) { + return new Query(Criteria.where("_id").is(event.getId()) + .and("leaseToken").is(event.getLeaseToken()).and("state").is(State.PENDING)); + } + + private Update clearLease() { return new Update().unset("leaseUntil").unset("leaseToken"); } +} diff --git a/backend/src/main/java/net/modtale/service/ModjamService.java b/backend/src/main/java/net/modtale/service/ModjamService.java new file mode 100644 index 000000000..d84328540 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/ModjamService.java @@ -0,0 +1,1245 @@ +package net.modtale.service; + +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamSubmission; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.jam.ModjamSubmissionRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.storage.StorageService; +import net.modtale.service.security.validation.FileValidationService; +import net.modtale.service.jam.ModjamCustomizationService; +import net.modtale.service.jam.ModjamOrganizerService; +import net.modtale.service.jam.ModjamDiscordFeedService; +import net.modtale.model.jam.Modjam.JamPermission; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.beans.BeanWrapperImpl; +import net.modtale.service.jam.ModjamPhase; +import net.modtale.service.jam.ModjamVotePersistence; +import net.modtale.service.jam.ModjamMembershipPersistence; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.project.lifecycle.LifecycleService; +import net.modtale.service.user.account.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.BeanUtils; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; +import java.util.zip.ZipFile; + +@Service +public class ModjamService { + + @Autowired private ModjamRepository modjamRepository; + @Autowired private ModjamSubmissionRepository submissionRepository; + @Autowired private UserRepository userRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private StorageService storageService; + @Autowired private FileValidationService fileValidationService; + @Autowired private MongoTemplate mongoTemplate; + @Autowired private AccountService accountService; + @Autowired private ModjamCustomizationService customizationService; + @Autowired private ModjamOrganizerService organizerService; + @Autowired private ModjamDiscordFeedService feedService; + @Autowired private LifecycleService lifecycleService; + @Autowired private AccessControlService accessControlService; + @Autowired private ModjamVotePersistence votePersistence; + @Autowired private ModjamMembershipPersistence membershipPersistence; + + @Value("${app.r2.public-domain:#{null}}") + private String publicDomain; + + private static final RuntimeException FOUND_USAGE = new RuntimeException("Found usage of class or package", null, false, false) {}; + + public static final class CheckJarUseClass { + public static boolean checkUseClass(final File file, final String classOrPackage) { + final String searchPrefix = classOrPackage.replace('.', '/').replace("*", ""); + try (final ZipFile zipFile = new ZipFile(file)) { + zipFile.stream().filter(entry -> entry.getName().endsWith(".class")) + .forEach(zipEntry -> { + try { + new ClassReader(zipFile.getInputStream(zipEntry)) + .accept(new PrefixUsageSearcher(searchPrefix), + ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } catch (Exception e) { + if (e == FOUND_USAGE) { + return true; + } + } + return false; + } + + private static class PrefixUsageSearcher extends ClassVisitor { + private final String prefix; + + public PrefixUsageSearcher(String prefix) { + super(Opcodes.ASM9); + this.prefix = prefix; + } + + private void check(String internalName) { + if (internalName != null && internalName.startsWith(prefix)) { + throw FOUND_USAGE; + } + } + + private void checkDescriptor(String descriptor) { + if (descriptor != null && descriptor.contains(prefix)) { + throw FOUND_USAGE; + } + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + check(superName); + if (interfaces != null) { + for (String i : interfaces) check(i); + } + super.visit(version, access, name, signature, superName, interfaces); + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { + checkDescriptor(descriptor); + return super.visitField(access, name, descriptor, signature, value); + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + checkDescriptor(descriptor); + return new MethodVisitor(Opcodes.ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)) { + @Override + public void visitMethodInsn(int opcode, String owner, String mName, String mDescriptor, boolean isInterface) { + check(owner); + super.visitMethodInsn(opcode, owner, mName, mDescriptor, isInterface); + } + + @Override + public void visitFieldInsn(int opcode, String owner, String fName, String fDescriptor) { + check(owner); + super.visitFieldInsn(opcode, owner, fName, fDescriptor); + } + + @Override + public void visitTypeInsn(int opcode, String type) { + check(type); + super.visitTypeInsn(opcode, type); + } + }; + } + } + } + + private Modjam enrichAndReturn(Modjam jam) { + if (jam != null && jam.getJudgeIds() != null && !jam.getJudgeIds().isEmpty()) { + List> profiles = new ArrayList<>(); + for (String jId : jam.getJudgeIds()) { + userRepository.findById(jId).ifPresent(u -> { + Map p = new HashMap<>(); + p.put("id", u.getId()); + p.put("username", u.getUsername()); + p.put("avatarUrl", u.getAvatarUrl()); + profiles.add(p); + }); + } + jam.setJudgeProfiles(profiles); + } + return jam; + } + + private User browserViewer() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getAuthorities().stream().anyMatch(authority -> "ROLE_API".equals(authority.getAuthority()))) return null; + return accountService.getCurrentUser(); + } + + private Modjam publicJam(Modjam jam) { + Modjam response = new Modjam(); + BeanUtils.copyProperties(jam, response); + response.setPendingJudgeInvites(new ArrayList<>()); + response.setPendingJudgeInviteUsers(new LinkedHashMap<>()); + response.setPendingOrganizerInvites(new ArrayList<>()); + return enrichAndReturn(response); + } + + public Modjam jamForViewer(Modjam jam, User viewer) { + return jamForViewer(jam, viewer == null ? null : viewer.getId(), viewer == null ? null : viewer.getUsername()); + } + + private Modjam jamForViewer(Modjam jam, String viewerId, String viewerName) { + if (jam == null) return null; + if (viewerId != null && Objects.equals(jam.getHostId(), viewerId)) return enrichAndReturn(jam); + Modjam response = publicJam(jam); + if (viewerId != null && jam.getPendingOrganizerInvites() != null) { + response.setPendingOrganizerInvites(jam.getPendingOrganizerInvites().stream().filter(invite -> viewerId.equals(invite.userId())).toList()); + } + if (viewerId != null && jam.getPendingJudgeInviteUsers() != null && jam.getPendingJudgeInviteUsers().containsKey(viewerId)) { + String name = viewerName == null ? jam.getPendingJudgeInviteUsers().get(viewerId) : viewerName; + if (name != null) response.setPendingJudgeInvites(List.of(name)); + } + return response; + } + + private void requireUpdatePermissions(Modjam jam, Modjam update, String userId) { + if (Objects.equals(jam.getHostId(), userId)) return; + if (!organizerService.isOrganizer(jam, userId)) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only organizers can edit this jam."); + if (!Objects.equals(jam.getTitle(), update.getTitle()) || !Objects.equals(jam.getDescription(), update.getDescription()) + || !Objects.equals(jam.getSlug(), update.getSlug()) || !Objects.equals(jam.getCustomCss(), update.getCustomCss())) { + organizerService.require(jam, userId, JamPermission.EDIT_DETAILS); + } + if (!Objects.equals(jam.getRules(), update.getRules())) organizerService.require(jam, userId, JamPermission.EDIT_RULES); + if (!Objects.equals(jam.getStartDate(), update.getStartDate()) || !Objects.equals(jam.getEndDate(), update.getEndDate()) + || !Objects.equals(jam.getVotingEndDate(), update.getVotingEndDate()) || !Objects.equals(jam.getStatus(), update.getStatus()) + || jam.isAllowPublicVoting() != update.isAllowPublicVoting() || jam.isAllowConcurrentVoting() != update.isAllowConcurrentVoting() + || jam.isShowResultsBeforeVotingEnds() != update.isShowResultsBeforeVotingEnds() || jam.isOneEntryPerPerson() != update.isOneEntryPerPerson() + || jam.isHideSubmissions() != update.isHideSubmissions() || !Objects.equals(beanProperties(jam.getRestrictions()), beanProperties(update.getRestrictions())) + || !Objects.equals(categoryProperties(jam.getCategories()), categoryProperties(update.getCategories()))) { + organizerService.require(jam, userId, JamPermission.MANAGE_SETTINGS); + } + } + + private Modjam persistEditableFields(Modjam jam, String expectedStatus) { + // Host edits must not replace participants, ballots, invitations or role + // grants changed by another request while the edit form was open. + org.springframework.data.mongodb.core.query.Update update = new org.springframework.data.mongodb.core.query.Update(); + for (String field : List.of("slug", "title", "description", "rules", "customCss", "startDate", "endDate", "votingEndDate", + "allowPublicVoting", "allowConcurrentVoting", "showResultsBeforeVotingEnds", "oneEntryPerPerson", "hideSubmissions", + "restrictions", "categories", "status", "publishedAt", "updatedAt")) { + update.set(field, new BeanWrapperImpl(jam).getPropertyValue(field)); + } + Criteria expected = Criteria.where("_id").is(jam.getId()).and("status").is(expectedStatus); + if (!"COMPLETED".equals(expectedStatus)) expected.and("finalizationHash").exists(false); + var persisted = mongoTemplate.updateFirst(Query.query(expected), update, Modjam.class); + if (persisted.getMatchedCount() == 0) throw new ResponseStatusException(HttpStatus.CONFLICT, "This jam changed or was finalized. Refresh before saving."); + return jam; + } + + private static Map beanProperties(Object bean) { + if (bean == null) return Map.of(); + var wrapper = new BeanWrapperImpl(bean); + Map result = new HashMap<>(); + for (var property : wrapper.getPropertyDescriptors()) { + if (!"class".equals(property.getName()) && wrapper.isReadableProperty(property.getName())) { + result.put(property.getName(), wrapper.getPropertyValue(property.getName())); + } + } + return result; + } + + private static List> categoryProperties(List categories) { + return categories == null ? List.of() : categories.stream().map(ModjamService::beanProperties).toList(); + } + + private void requireHost(Modjam jam, String userId) { + if (!Objects.equals(jam.getHostId(), userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the host can manage this jam"); + } + } + + + private static String effectivePhase(Modjam jam, Instant now) { + return ModjamPhase.current(jam, now); + } + + + private boolean canManageResults(Modjam jam, String userId) { + return userId != null && (organizerService.permits(jam, userId, JamPermission.VIEW_RESULTS) + || (jam.getJudgeIds() != null && jam.getJudgeIds().contains(userId))); + } + + private ModjamSubmission submissionForViewer(Modjam jam, ModjamSubmission submission, String userId, boolean isAdmin) { + // Project a separate response object: never redact a document that will be saved. + ModjamSubmission response = new ModjamSubmission(); + BeanUtils.copyProperties(submission, response); + boolean canManage = isAdmin || canManageResults(jam, userId); + response.setVotes(submission.getVotes() == null ? new ArrayList<>() : submission.getVotes().stream() + .filter(vote -> canManage || (userId != null && Objects.equals(vote.getVoterId(), userId))) + .toList()); + + boolean resultsVisible = canManage || jam.isShowResultsBeforeVotingEnds() + || List.of("COMPLETED", "AWAITING_WINNERS").contains(effectivePhase(jam, Instant.now())); + if (!resultsVisible) { + response.setCategoryScores(null); + response.setTotalScore(null); + response.setJudgeCategoryScores(null); + response.setTotalJudgeScore(null); + response.setTotalPublicScore(null); + response.setRank(null); + } + return response; + + } + + private void enrichSubmissions(String jamId, List subs, Map projectMap) { + if (subs == null || subs.isEmpty()) return; + + Map userVoteCount = new HashMap<>(); + Set visibleProjectIds = new HashSet<>(); + + for (ModjamSubmission s : subs) { + visibleProjectIds.add(s.getProjectId()); + if (s.getVotes() != null) { + for (ModjamSubmission.Vote v : s.getVotes()) { + userVoteCount.put(v.getVoterId(), userVoteCount.getOrDefault(v.getVoterId(), 0) + 1); + } + } + } + + Map userCommentCount = new HashMap<>(); + for (Project p : projectMap.values()) { + if (visibleProjectIds.contains(p.getId()) && p.getComments() != null) { + for (net.modtale.model.project.Comment c : p.getComments()) { + if (!c.getUserId().equals(p.getAuthorId())) { + userCommentCount.put(c.getUserId(), userCommentCount.getOrDefault(c.getUserId(), 0) + 1); + } + } + } + } + + for (ModjamSubmission sub : subs) { + Project project = projectMap.get(sub.getProjectId()); + if (project != null) { + sub.setProjectTitle(project.getTitle()); + sub.setProjectImageUrl(project.getImageUrl()); + sub.setProjectBannerUrl(project.getBannerUrl()); + sub.setProjectAuthor(project.getAuthor()); + sub.setProjectDescription(project.getDescription()); + } + sub.setVotesCast(userVoteCount.getOrDefault(sub.getSubmitterId(), 0)); + sub.setCommentsGiven(userCommentCount.getOrDefault(sub.getSubmitterId(), 0)); + } + } + + private void enrichSubmissions(String jamId, List subs) { + if (subs == null || subs.isEmpty()) return; + List projectIds = subs.stream().map(ModjamSubmission::getProjectId).toList(); + Iterable projectsIterable = projectRepository.findAllById(projectIds); + Map projectMap = new HashMap<>(); + projectsIterable.forEach(p -> projectMap.put(p.getId(), p)); + enrichSubmissions(jamId, subs, projectMap); + } + + private String extractStorageKey(String fileUrl) { + if (fileUrl == null) return null; + if (fileUrl.startsWith("/api/files/proxy/")) { + return fileUrl.replace("/api/files/proxy/", ""); + } else if (publicDomain != null && fileUrl.startsWith(publicDomain + "/")) { + return fileUrl.replace(publicDomain + "/", ""); + } else if (publicDomain != null && fileUrl.startsWith(publicDomain)) { + return fileUrl.replace(publicDomain, ""); + } + return fileUrl; + } + + public List getAllJams() { + return modjamRepository.findAll().stream().filter(jam -> !"DRAFT".equals(jam.getStatus())).map(this::publicJam).collect(Collectors.toList()); + } + + public List getUserHostedJams(String hostId) { + return modjamRepository.findAll().stream().filter(jam -> organizerService.isOrganizer(jam, hostId)).map(jam -> jamForViewer(jam, hostId, null)).collect(Collectors.toList()); + } + + public Modjam getJamBySlug(String slug) { + Modjam jam = modjamRepository.findBySlug(slug).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Jam not found")); + User viewer = browserViewer(); + String viewerId = viewer == null ? null : viewer.getId(); + boolean invited = viewerId != null && jam.getPendingOrganizerInvites() != null + && jam.getPendingOrganizerInvites().stream().anyMatch(invite -> viewerId.equals(invite.userId())); + if ("DRAFT".equals(jam.getStatus()) && !organizerService.isOrganizer(jam, viewerId) && !invited) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Jam not found"); + } + return jamForViewer(jam, viewerId, viewer == null ? null : viewer.getUsername()); + } + + public Modjam createJam(Modjam jam, String hostId, String hostName) { + jam.setId(null); + jam.setHostId(hostId); + jam.setHostName(hostName); + jam.setPublishedAt(null); + jam.setWinnersAnnouncedAt(null); + customizationService.validate(jam); + if ("COMPLETED".equals(jam.getStatus())) throw new IllegalArgumentException("Create a draft and announce winners after voting closes."); + // Incoming document fields do not confer membership or management authority. + jam.setParticipantIds(new ArrayList<>()); + jam.setJudgeIds(new ArrayList<>()); + jam.setPendingJudgeInvites(new ArrayList<>()); + jam.setPendingJudgeInviteUsers(new LinkedHashMap<>()); + jam.setOrganizerRoles(new ArrayList<>()); + jam.setOrganizerMembers(new ArrayList<>()); + jam.setPendingOrganizerInvites(new ArrayList<>()); + + if (jam.getSlug() == null || jam.getSlug().trim().isEmpty()) { + throw new IllegalArgumentException("A custom URL slug is required."); + } + String newSlug = jam.getSlug().toLowerCase(); + if (!newSlug.matches("^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])?$")) { + throw new IllegalArgumentException("Invalid URL Slug. Must be 3-50 characters, lowercase alphanumeric with dashes, and cannot start or end with a dash."); + } + if (modjamRepository.findBySlug(newSlug).isPresent()) { + throw new IllegalArgumentException("Jam URL '" + newSlug + "' is already taken."); + } + jam.setSlug(newSlug); + + if (!"DRAFT".equals(jam.getStatus())) { + jam.setStatus(effectivePhase(jam, Instant.now())); + jam.setPublishedAt(Instant.now()); + } + + jam.setCreatedAt(Instant.now()); + jam.setUpdatedAt(Instant.now()); + + if (jam.getCategories() != null) { + for (Modjam.Category cat : jam.getCategories()) { + if (cat.getId() == null || cat.getId().trim().isEmpty()) { + cat.setId(UUID.randomUUID().toString()); + } + } + } else { + jam.setCategories(new ArrayList<>()); + } + + if (jam.getJudgeIds() == null) jam.setJudgeIds(new ArrayList<>()); + if (jam.getPendingJudgeInvites() == null) jam.setPendingJudgeInvites(new ArrayList<>()); + + Modjam saved = modjamRepository.save(jam); + feedService.recordMilestones(saved); + return enrichAndReturn(saved); + } + + public Modjam updateJam(String id, Modjam updatedJam, String userId) { + Modjam jam = modjamRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + requireUpdatePermissions(jam, updatedJam, userId); + customizationService.validate(updatedJam); + if ("COMPLETED".equals(updatedJam.getStatus()) != "COMPLETED".equals(jam.getStatus())) { + throw new IllegalArgumentException("Announce winners to complete a jam; completed jams cannot be reopened."); + } + + if (updatedJam.getSlug() == null || updatedJam.getSlug().trim().isEmpty()) { + throw new IllegalArgumentException("A custom URL slug is required."); + } + String newSlug = updatedJam.getSlug().toLowerCase(); + if (!newSlug.equals(jam.getSlug())) { + if (!newSlug.matches("^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])?$")) { + throw new IllegalArgumentException("Invalid URL Slug. Must be 3-50 characters, lowercase alphanumeric with dashes, and cannot start or end with a dash."); + } + if (modjamRepository.findBySlug(newSlug).isPresent()) { + throw new IllegalArgumentException("Jam URL '" + newSlug + "' is already taken."); + } + jam.setSlug(newSlug); + } + + String oldStatus = jam.getStatus(); + + jam.setTitle(updatedJam.getTitle()); + jam.setDescription(updatedJam.getDescription()); + jam.setRules(updatedJam.getRules()); + jam.setCustomCss(updatedJam.getCustomCss()); + jam.setStartDate(updatedJam.getStartDate()); + jam.setEndDate(updatedJam.getEndDate()); + jam.setVotingEndDate(updatedJam.getVotingEndDate()); + jam.setAllowPublicVoting(updatedJam.isAllowPublicVoting()); + jam.setAllowConcurrentVoting(updatedJam.isAllowConcurrentVoting()); + jam.setShowResultsBeforeVotingEnds(updatedJam.isShowResultsBeforeVotingEnds()); + jam.setOneEntryPerPerson(updatedJam.isOneEntryPerPerson()); + jam.setHideSubmissions(updatedJam.isHideSubmissions()); + + if (updatedJam.getRestrictions() != null) { + jam.setRestrictions(updatedJam.getRestrictions()); + } + + if (updatedJam.getCategories() != null) { + for (Modjam.Category cat : updatedJam.getCategories()) { + if (cat.getId() == null || cat.getId().trim().isEmpty()) { + cat.setId(UUID.randomUUID().toString()); + } + } + jam.setCategories(updatedJam.getCategories()); + } else { + jam.setCategories(new ArrayList<>()); + } + + if (!"COMPLETED".equals(jam.getStatus()) && "COMPLETED".equals(updatedJam.getStatus())) { + calculateScores(jam.getId()); + } + + String targetStatus = updatedJam.getStatus(); + if (!"COMPLETED".equals(targetStatus) && !"DRAFT".equals(targetStatus)) { + Instant now = Instant.now(); + if (jam.getStartDate() != null && now.isBefore(jam.getStartDate())) { + targetStatus = "UPCOMING"; + } else if (jam.getEndDate() != null && now.isBefore(jam.getEndDate())) { + targetStatus = "ACTIVE"; + } else if (jam.getVotingEndDate() != null && now.isBefore(jam.getVotingEndDate())) { + targetStatus = "VOTING"; + } else { + targetStatus = "AWAITING_WINNERS"; + } + } + + jam.setStatus(targetStatus); + if ("DRAFT".equals(oldStatus) && !"DRAFT".equals(targetStatus) && jam.getPublishedAt() == null) jam.setPublishedAt(Instant.now()); + jam.setUpdatedAt(Instant.now()); + + if (jam.isHideSubmissions() && !List.of("VOTING", "COMPLETED", "AWAITING_WINNERS").contains(oldStatus) + && List.of("VOTING", "COMPLETED", "AWAITING_WINNERS").contains(targetStatus)) { + revealHiddenJamProjects(jam.getId()); + } + + Modjam saved = persistEditableFields(jam, oldStatus); + feedService.recordMilestones(saved); + return enrichAndReturn(saved); + } + + public Modjam inviteJudge(String jamId, String username, String hostId) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + organizerService.require(jam, hostId, JamPermission.MANAGE_JUDGES); + + User targetUser = username == null ? null : userRepository.findByUsernameIgnoreCase(username.trim()).orElse(null); + + if (targetUser == null || targetUser.isDeleted()) { + throw new IllegalArgumentException("User '" + username + "' not found."); + } + + if (targetUser.getId().equals(hostId)) { + throw new IllegalArgumentException("You cannot invite yourself."); + } + + if (jam.getPendingJudgeInvites() == null) jam.setPendingJudgeInvites(new ArrayList<>()); + if (jam.getJudgeIds() == null) jam.setJudgeIds(new ArrayList<>()); + + if (jam.getJudgeIds().contains(targetUser.getId())) { + throw new IllegalArgumentException("User is already a judge."); + } + if (jam.getPendingJudgeInviteUsers() != null && jam.getPendingJudgeInviteUsers().containsKey(targetUser.getId())) { + throw new IllegalArgumentException("User is already invited."); + } + + Modjam updated = membershipPersistence.inviteJudge(jamId, targetUser.getId(), targetUser.getUsername()); + if (updated == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "This invitation changed. Refresh the jam before inviting again."); + } + + org.bson.Document notif = new org.bson.Document(); + notif.put("userId", targetUser.getId()); + notif.put("title", "Jam Judge Invitation"); + notif.put("message", "You have been invited to be a judge for " + jam.getTitle()); + notif.put("link", "/jam/" + jam.getSlug() + "/overview"); + notif.put("read", false); + notif.put("createdAt", Instant.now()); + mongoTemplate.save(notif, "notifications"); + + return enrichAndReturn(updated); + } + + public Modjam removeJudge(String jamId, String username, String hostId) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + organizerService.require(jam, hostId, JamPermission.MANAGE_JUDGES); + + Set userIds = new LinkedHashSet<>(); + if (jam.getPendingJudgeInviteUsers() != null) { + jam.getPendingJudgeInviteUsers().entrySet().stream() + .filter(entry -> entry.getValue() != null && entry.getValue().equalsIgnoreCase(username)) + .forEach(entry -> userIds.add(entry.getKey())); + } + User targetUser = username == null ? null : userRepository.findByUsernameIgnoreCase(username.trim()).orElse(null); + if (targetUser != null) userIds.add(targetUser.getId()); + Modjam updated = membershipPersistence.removeJudges(jamId, new ArrayList<>(userIds)); + if (updated == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Jam no longer exists."); + return enrichAndReturn(updated); + } + + public Modjam acceptJudgeInvite(String jamId, String userId, String username) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (jam.getPendingJudgeInviteUsers() == null || !jam.getPendingJudgeInviteUsers().containsKey(userId)) { + throw new IllegalArgumentException("You don't have a pending invite for this jam."); + } + + Modjam updated = membershipPersistence.answerJudgeInvite(jamId, userId, true); + if (updated == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "This invitation is no longer pending."); + return jamForViewer(updated, userId, username); + } + + public Modjam declineJudgeInvite(String jamId, String userId, String username) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (jam.getPendingJudgeInviteUsers() == null || !jam.getPendingJudgeInviteUsers().containsKey(userId)) { + return jamForViewer(jam, userId, username); + } + Modjam updated = membershipPersistence.answerJudgeInvite(jamId, userId, false); + if (updated == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "This invitation is no longer pending."); + return jamForViewer(updated, userId, username); + } + + public void updateIcon(String jamId, MultipartFile file, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + organizerService.require(jam, userId, JamPermission.EDIT_DETAILS); + fileValidationService.validateIcon(file); + + try { + String pathPrefix = "modjams/" + jamId + "/icon"; + String storageKey = storageService.upload(file, pathPrefix); + String publicUrl = storageService.getPublicUrl(storageKey); + jam.setImageUrl(publicUrl); + jam.setUpdatedAt(Instant.now()); + mongoTemplate.updateFirst(Query.query(Criteria.where("_id").is(jamId)), + new org.springframework.data.mongodb.core.query.Update().set("imageUrl", publicUrl).set("updatedAt", jam.getUpdatedAt()), Modjam.class); + } catch (Exception e) { + throw new RuntimeException("Failed to upload icon", e); + } + } + + public void updateBanner(String jamId, MultipartFile file, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + organizerService.require(jam, userId, JamPermission.EDIT_DETAILS); + fileValidationService.validateBanner(file); + + try { + String pathPrefix = "modjams/" + jamId + "/banner"; + String storageKey = storageService.upload(file, pathPrefix); + String publicUrl = storageService.getPublicUrl(storageKey); + jam.setBannerUrl(publicUrl); + jam.setUpdatedAt(Instant.now()); + mongoTemplate.updateFirst(Query.query(Criteria.where("_id").is(jamId)), + new org.springframework.data.mongodb.core.query.Update().set("bannerUrl", publicUrl).set("updatedAt", jam.getUpdatedAt()), Modjam.class); + } catch (Exception e) { + throw new RuntimeException("Failed to upload banner", e); + } + } + + public void deleteJam(String jamId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (!jam.getHostId().equals(userId)) { + throw new SecurityException("Only the host can delete this jam"); + } + + List submissions = submissionRepository.findByJamId(jamId); + if (submissions != null && !submissions.isEmpty()) { + submissionRepository.deleteAll(submissions); + } + + modjamRepository.delete(jam); + } + + public List getSubmissions(String jamId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + List allSubs = submissionRepository.findByJamId(jamId); + + if (allSubs == null || allSubs.isEmpty()) return new ArrayList<>(); + + User currentUser = accountService.getCurrentUser(); + // A scoped API key is never a browser viewer with its owner's private access. + if (accessControlService.isApiKey(SecurityContextHolder.getContext().getAuthentication())) currentUser = null; + boolean isAdmin = accessControlService.canViewPrivilegedProjectData(currentUser); + boolean isHost = currentUser != null && organizerService.permits(jam, currentUser.getId(), JamPermission.VIEW_RESULTS); + + boolean isJamHiding = ModjamPhase.hidesEntries(jam, Instant.now()); + + List projectIds = allSubs.stream().map(ModjamSubmission::getProjectId).toList(); + Iterable projectsIterable = projectRepository.findAllById(projectIds); + Map projectMap = new HashMap<>(); + projectsIterable.forEach(p -> projectMap.put(p.getId(), p)); + + List visibleSubs = new ArrayList<>(); + + for (ModjamSubmission sub : allSubs) { + Project project = projectMap.get(sub.getProjectId()); + if (project == null) continue; + + boolean isSubmitter = currentUser != null && currentUser.getId().equals(sub.getSubmitterId()); + boolean canSeeHidden = isAdmin || isHost || isSubmitter; + boolean isPublicProject = project.getStatus() == ProjectStatus.PUBLISHED || project.getStatus() == ProjectStatus.ARCHIVED; + + if (!canSeeHidden) { + if (!isPublicProject || isJamHiding) { + continue; // Backend enforces filtering out unreleased or jam-hidden projects + } + } + visibleSubs.add(sub); + } + + enrichSubmissions(jamId, visibleSubs, projectMap); + String viewerId = currentUser == null ? null : currentUser.getId(); + return visibleSubs.stream().map(sub -> submissionForViewer(jam, sub, viewerId, isAdmin)).toList(); + } + + public Modjam participate(String jamId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + if (!List.of("UPCOMING", "ACTIVE").contains(effectivePhase(jam, Instant.now()))) { + throw new IllegalArgumentException("Participation is closed for this jam."); + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (user.getJoinedModjamIds() != null) { + for (String joinedId : user.getJoinedModjamIds()) { + if (joinedId.equals(jamId)) continue; + + Optional optOtherJam = modjamRepository.findById(joinedId); + if (optOtherJam.isPresent()) { + Modjam otherJam = optOtherJam.get(); + boolean otherIsActive = List.of("ACTIVE", "UPCOMING").contains(effectivePhase(otherJam, Instant.now())); + boolean thisIsActive = List.of("ACTIVE", "UPCOMING").contains(effectivePhase(jam, Instant.now())); + + if (otherIsActive && thisIsActive) { + boolean otherRequiresUnique = otherJam.getRestrictions() != null && otherJam.getRestrictions().isRequireUniqueSubmission(); + boolean thisRequiresUnique = jam.getRestrictions() != null && jam.getRestrictions().isRequireUniqueSubmission(); + + if (otherRequiresUnique) { + throw new IllegalStateException("You are currently participating in '" + otherJam.getTitle() + "' which requires unique participation. You must leave it to join this jam."); + } + if (thisRequiresUnique) { + throw new IllegalStateException("This jam requires unique participation. You are currently in '" + otherJam.getTitle() + "'. You must leave it to join this jam."); + } + } + } + } + } + + Modjam updated = membershipPersistence.participate(jamId, userId, true); + if (updated == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Jam no longer exists."); + return jamForViewer(updated, userId, null); + } + + public Modjam leaveJam(String jamId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + List existingSubs = submissionRepository.findByJamIdAndSubmitterId(jamId, userId); + if (!existingSubs.isEmpty()) { + throw new IllegalArgumentException("Cannot leave a jam after submitting a project."); + } + + Modjam updated = membershipPersistence.participate(jamId, userId, false); + if (updated == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Jam no longer exists."); + return jamForViewer(updated, userId, null); + } + + public ModjamSubmission submitProject(String jamId, String projectId, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new IllegalArgumentException("Jam not found")); + + if (!"ACTIVE".equals(effectivePhase(jam, Instant.now()))) { + throw new IllegalArgumentException("Submissions are closed."); + } + + if (jam.getParticipantIds() == null || !jam.getParticipantIds().contains(userId)) { + throw new IllegalArgumentException("Join this jam before submitting a project."); + } + + if (projectId == null || projectId.isBlank()) { + throw new IllegalArgumentException("Select a project to submit."); + } + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Project not found")); + + if (!project.getAuthorId().equals(userId)) { + throw new SecurityException("Not your project"); + } + + if (!List.of(ProjectStatus.PUBLISHED, ProjectStatus.PENDING, ProjectStatus.UNLISTED, ProjectStatus.DRAFT).contains(project.getStatus())) { + throw new IllegalArgumentException("Project cannot be submitted in its current state."); + } + + if (jam.isHideSubmissions() && accessControlService.isPubliclyReadable(project)) { + throw new IllegalArgumentException("This jam hides submissions until voting opens. You cannot submit an already-public project."); + } + + List existing = submissionRepository.findByJamIdAndSubmitterId(jamId, userId); + + if (jam.isOneEntryPerPerson() && !existing.isEmpty()) { + throw new IllegalArgumentException("This jam is restricted to one entry per person."); + } + + if (existing.stream().anyMatch(s -> s.getProjectId().equals(projectId))) { + throw new IllegalArgumentException("Already submitted."); + } + if (submissionRepository.findByProjectId(projectId).stream().anyMatch(s -> Objects.equals(s.getJamId(), jamId))) { + throw new IllegalArgumentException("This project already has an entry in this jam."); + } + + Modjam.Restrictions res = jam.getRestrictions(); + if (res != null) { + if (res.isRequireNoPriorProjects() || res.isRequirePriorProjects()) { + long priorPublishedProjects = projectRepository.findByAuthorIdList(userId).stream() + .filter(p -> !p.getId().equals(projectId) && p.getStatus() == ProjectStatus.PUBLISHED) + .count(); + + if (res.isRequireNoPriorProjects() && priorPublishedProjects > 0) { + throw new IllegalArgumentException("This jam is restricted to users who have never published a project before."); + } + if (res.isRequirePriorProjects() && priorPublishedProjects == 0) { + throw new IllegalArgumentException("This jam is restricted to users who have previously published at least one project."); + } + } + + if (res.isRequireNewProject() && jam.getStartDate() != null && project.getCreatedAt() != null) { + try { + Instant projCreated; + try { + projCreated = Instant.parse(project.getCreatedAt()); + } catch (Exception e) { + String cleanDate = project.getCreatedAt().replace("Z", ""); + projCreated = LocalDateTime.parse(cleanDate).toInstant(ZoneOffset.UTC); + } + if (projCreated.isBefore(jam.getStartDate())) { + throw new IllegalArgumentException("Project must be created after the jam start date."); + } + } catch (IllegalArgumentException rse) { + throw rse; + } catch (Exception ignored) {} + } + + if (res.isRequireSourceRepo() && (project.getRepositoryUrl() == null || project.getRepositoryUrl().trim().isEmpty())) { + throw new IllegalArgumentException("Project must have a linked public source repository."); + } + + if (res.isRequireOsiLicense()) { + String l = project.getLicense() != null ? project.getLicense().toUpperCase().replaceAll("[^A-Z0-9]", "") : ""; + boolean isOsi = l.contains("MIT") || l.contains("APACHE") || l.contains("LGPL") || l.contains("AGPL") || l.contains("GPL") || l.contains("MPL") || l.contains("BSD") || l.contains("UNLICENSE") || l.contains("CC0"); + if (!isOsi) { + throw new IllegalArgumentException("Project must use an OSI-approved open source license."); + } + } + + if (res.getAllowedClassifications() != null && !res.getAllowedClassifications().isEmpty()) { + if (!res.getAllowedClassifications().contains(project.getClassification().name())) { + throw new IllegalArgumentException("Project classification is not allowed for this jam."); + } + } + + if (res.getAllowedLicenses() != null && !res.getAllowedLicenses().isEmpty()) { + if (!res.getAllowedLicenses().contains(project.getLicense())) { + throw new IllegalArgumentException("Project license is not allowed for this jam."); + } + } + + customizationService.validateProjectVersions(res, project); + + if (res.getRequiredDependencyId() != null && !res.getRequiredDependencyId().trim().isEmpty()) { + String requiredDependencyId = res.getRequiredDependencyId().trim(); + boolean hasRequiredDependency = project.getVersions() != null && project.getVersions().stream() + .filter(java.util.Objects::nonNull) + .filter(version -> version.getDependencies() != null) + .flatMap(version -> version.getDependencies().stream()) + .filter(java.util.Objects::nonNull) + .anyMatch(dependency -> requiredDependencyId.equals(dependency.getProjectId())); + if (!hasRequiredDependency) { + throw new IllegalArgumentException("Project is missing the required dependency."); + } + } + + int contributorCount = (project.getTeamMembers() != null ? project.getTeamMembers().size() : 0) + 1; + if (res.getMinContributors() != null && contributorCount < res.getMinContributors()) { + throw new IllegalArgumentException("Project does not meet the minimum contributor requirement."); + } + + if (res.getMaxContributors() != null && contributorCount > res.getMaxContributors()) { + throw new IllegalArgumentException("Project exceeds the maximum contributor limit."); + } + + if (res.isRequireUniqueSubmission() && project.getModjamIds() != null) { + for (String otherJamId : project.getModjamIds()) { + if (otherJamId.equals(jamId)) continue; + modjamRepository.findById(otherJamId).ifPresent(otherJam -> { + if ("ACTIVE".equals(otherJam.getStatus()) || "VOTING".equals(otherJam.getStatus())) { + throw new IllegalArgumentException("Project is currently entered in another active jam."); + } + }); + } + } + + if (res.isRequireNewbie()) { + User u = userRepository.findById(userId).orElse(null); + if (u != null && u.getJoinedModjamIds() != null) { + long activeJams = u.getJoinedModjamIds().stream().filter(id -> !id.equals(jamId)).count(); + if (activeJams > 0) { + throw new IllegalArgumentException("This jam is restricted to first-time participants."); + } + } + } + + if (res.isRequirePriorJams()) { + User u = userRepository.findById(userId).orElse(null); + if (u == null || u.getJoinedModjamIds() == null || u.getJoinedModjamIds().stream().filter(id -> !id.equals(jamId)).count() == 0) { + throw new IllegalArgumentException("This jam is restricted to experienced participants who have joined a jam before."); + } + } + + if (res.getRequiredClassUsage() != null && !res.getRequiredClassUsage().trim().isEmpty()) { + if (project.getVersions() == null || project.getVersions().isEmpty()) { + throw new IllegalArgumentException("Project has no uploaded files to check."); + } + + ProjectVersion latestVersion = project.getVersions().get(project.getVersions().size() - 1); + String fileUrl = latestVersion.getFileUrl(); + if (fileUrl == null || fileUrl.isEmpty()) { + throw new IllegalArgumentException("Project version has no file associated."); + } + + String storageKey = extractStorageKey(fileUrl); + File tempFile = null; + try { + tempFile = Files.createTempFile("jam_check_", ".jar").toFile(); + try (InputStream is = storageService.getStream(storageKey); + FileOutputStream fos = new FileOutputStream(tempFile)) { + is.transferTo(fos); + } + + boolean usesClass = CheckJarUseClass.checkUseClass(tempFile, res.getRequiredClassUsage().trim()); + if (!usesClass) { + throw new IllegalArgumentException("Project does not use the required class/package: " + res.getRequiredClassUsage().trim()); + } + } catch (IllegalArgumentException rse) { + throw rse; + } catch (Exception e) { + throw new IllegalStateException("Failed to analyze project file for required class usage."); + } finally { + if (tempFile != null && tempFile.exists()) { + tempFile.delete(); + } + } + } + } + + // Jam eligibility is read-only. Only a fully eligible draft enters the + // canonical review workflow, including verification, validation and scans. + if (project.getStatus() == ProjectStatus.DRAFT) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + lifecycleService.submitProjectForModjam(projectId, user, jamId); + project = projectRepository.findById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Project not found")); + } + + ModjamSubmission sub = new ModjamSubmission(); + sub.setId(submissionIdentity(jamId, projectId, userId, jam.isOneEntryPerPerson())); + sub.setJamId(jamId); + sub.setProjectId(projectId); + sub.setSubmitterId(userId); + + try { + submissionRepository.insert(sub); + } catch (org.springframework.dao.DuplicateKeyException duplicate) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "This project or participant already has an entry in this jam."); + } + + if (project.getModjamIds() == null) project.setModjamIds(new ArrayList<>()); + if (!project.getModjamIds().contains(jamId)) { + project.getModjamIds().add(jamId); + mongoTemplate.updateFirst(Query.query(Criteria.where("_id").is(projectId)), + new org.springframework.data.mongodb.core.query.Update().addToSet("modjamIds", jamId), Project.class); + } + + enrichSubmissions(jamId, Collections.singletonList(sub)); + return sub; + } + + private static String submissionIdentity(String jamId, String projectId, String userId, boolean oneEntryPerPerson) { + String key = jamId + "\0" + (oneEntryPerPerson ? "person\0" + userId : "project\0" + projectId); + try { + return "modjam-" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(key.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("Submission identity could not be generated.", impossible); + } + } + + public ModjamSubmission vote(String jamId, String submissionId, String categoryId, int score, String userId) { + Modjam jam = modjamRepository.findById(jamId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Jam not found")); + Instant now = Instant.now(); + Instant votingStart = jam.isAllowConcurrentVoting() ? jam.getStartDate() : jam.getEndDate(); + if (!List.of("ACTIVE", "VOTING").contains(effectivePhase(jam, now)) || votingStart == null + || jam.getVotingEndDate() == null || now.isBefore(votingStart) + || !now.isBefore(jam.getVotingEndDate())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Voting is closed for this jam."); + } + + if (userId == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sign in to vote."); + } + boolean isJudge = Objects.equals(jam.getHostId(), userId) + || jam.getJudgeIds() != null && jam.getJudgeIds().contains(userId); + if (!isJudge && !jam.isAllowPublicVoting()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the host and invited judges can vote."); + } + + Modjam.Category category = jam.getCategories() == null ? null : jam.getCategories().stream() + .filter(Objects::nonNull).filter(cat -> Objects.equals(cat.getId(), categoryId)).findFirst().orElse(null); + if (categoryId == null || category == null || score < 1 || score > category.getMaxScore()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Choose a valid category and a score within its range."); + } + + ModjamSubmission sub = submissionRepository.findById(submissionId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Submission not found")); + if (!Objects.equals(sub.getJamId(), jamId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Submission not found in this jam."); + } + Project project = projectRepository.findById(sub.getProjectId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Project not found")); + if (Objects.equals(sub.getSubmitterId(), userId) || Objects.equals(project.getAuthorId(), userId) + || (project.getTeamMembers() != null && project.getTeamMembers().stream() + .filter(Objects::nonNull).anyMatch(member -> Objects.equals(member.getUserId(), userId)))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You cannot vote on your own entry."); + } + if (!List.of(ProjectStatus.PUBLISHED, ProjectStatus.ARCHIVED).contains(project.getStatus()) + || ModjamPhase.hidesEntries(jam, now)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "This entry is not available for voting."); + } + + ModjamSubmission.Vote vote = new ModjamSubmission.Vote(UUID.randomUUID().toString(), userId, categoryId, score, isJudge); + if (votePersistence.replaceBallot(jamId, submissionId, vote) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Submission no longer exists in this jam."); + } + calculateScores(jamId); + + ModjamSubmission updated = submissionRepository.findById(submissionId).orElse(sub); + enrichSubmissions(jamId, Collections.singletonList(updated)); + return submissionForViewer(jam, updated, userId, false); + } + + private void calculateScores(String jamId) { + List submissions = submissionRepository.findByJamId(jamId); + if (submissions == null || submissions.isEmpty()) return; + + for (ModjamSubmission sub : submissions) { + Map> allScoresMap = new HashMap<>(); + Map> publicScoresMap = new HashMap<>(); + Map> judgeScoresMap = new HashMap<>(); + + if (sub.getVotes() != null) { + for (ModjamSubmission.Vote vote : sub.getVotes()) { + allScoresMap.computeIfAbsent(vote.getCategoryId(), k -> new ArrayList<>()).add(vote.getScore()); + if (vote.isJudge()) { + judgeScoresMap.computeIfAbsent(vote.getCategoryId(), k -> new ArrayList<>()).add(vote.getScore()); + } else { + publicScoresMap.computeIfAbsent(vote.getCategoryId(), k -> new ArrayList<>()).add(vote.getScore()); + } + } + } + + sub.setCategoryScores(calculateAveragesMap(allScoresMap)); + sub.setTotalScore(calculateOverallAverage(allScoresMap)); + + sub.setJudgeCategoryScores(calculateAveragesMap(judgeScoresMap)); + sub.setTotalJudgeScore(calculateOverallAverage(judgeScoresMap)); + + sub.setTotalPublicScore(calculateOverallAverage(publicScoresMap)); + } + + submissions.sort((s1, s2) -> Double.compare(s2.getTotalScore() != null ? s2.getTotalScore() : 0.0, s1.getTotalScore() != null ? s1.getTotalScore() : 0.0)); + + int rank = 1; + for (ModjamSubmission sub : submissions) { + sub.setRank(rank++); + votePersistence.saveScores(sub); + } + } + + private Map calculateAveragesMap(Map> scoresMap) { + Map averaged = new HashMap<>(); + for (Map.Entry> entry : scoresMap.entrySet()) { + double avg = entry.getValue().stream().mapToInt(Integer::intValue).average().orElse(0.0); + averaged.put(entry.getKey(), avg); + } + return averaged; + } + + private Double calculateOverallAverage(Map> scoresMap) { + double totalSum = 0; + int count = 0; + for (Map.Entry> entry : scoresMap.entrySet()) { + totalSum += entry.getValue().stream().mapToInt(Integer::intValue).average().orElse(0.0); + count++; + } + return count > 0 ? totalSum / count : 0.0; + } + + private static String finalizationHash(Map awards) { + StringBuilder canonical = new StringBuilder(); + new TreeMap<>(awards).forEach((id, title) -> canonical.append(id.length()).append(':').append(id) + .append(title.length()).append(':').append(title)); + try { + return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(canonical.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is required for finalization integrity.", unavailable); + } + } + + public Modjam finalizeJam(String jamId, String userId, List> winnersData) { + Modjam jam = modjamRepository.findById(jamId).orElseThrow(() -> new IllegalArgumentException("Jam not found")); + organizerService.require(jam, userId, JamPermission.ANNOUNCE_WINNERS); + String phase = effectivePhase(jam, Instant.now()); + if (!"AWAITING_WINNERS".equals(phase) && !"COMPLETED".equals(phase)) { + throw new IllegalArgumentException("Wait for voting to close before finalizing this jam."); + } + + List allSubs = submissionRepository.findByJamId(jamId); + if (allSubs == null) allSubs = new ArrayList<>(); + Set submissionIds = allSubs.stream().map(ModjamSubmission::getId).collect(Collectors.toSet()); + if (winnersData == null) { + throw new IllegalArgumentException("Provide the winners for this jam."); + } + Map awards = new LinkedHashMap<>(); + for (Map winner : winnersData) { + if (winner == null || winner.get("submissionId") == null + || !submissionIds.contains(winner.get("submissionId"))) { + throw new IllegalArgumentException("Every winner must be a submission in this jam."); + } + String submissionId = winner.get("submissionId"); + String awardTitle = winner.get("awardTitle"); + if (awardTitle == null || awardTitle.isBlank() || awardTitle.length() > 200) { + throw new IllegalArgumentException("Award titles must contain 1-200 characters."); + } + if (awards.putIfAbsent(submissionId, awardTitle.trim()) != null) { + throw new IllegalArgumentException("A submission can only be selected once."); + } + } + + if ("COMPLETED".equals(jam.getStatus())) { + boolean identical = allSubs.stream().allMatch(sub -> + sub.isWinner() == awards.containsKey(sub.getId()) + && Objects.equals(sub.getAwardTitle(), awards.get(sub.getId()))); + if (!identical) { + throw new IllegalArgumentException("This jam has already been finalized."); + } + return enrichAndReturn(jam); + } + + String finalizationHash = finalizationHash(awards); + Criteria matchingClaim = new Criteria().orOperator(Criteria.where("finalizationHash").exists(false), + Criteria.where("finalizationHash").is(finalizationHash)); + Query claim = Query.query(new Criteria().andOperator(Criteria.where("_id").is(jamId).and("status").is(jam.getStatus()).and("updatedAt").is(jam.getUpdatedAt()) + .and("startDate").is(jam.getStartDate()).and("endDate").is(jam.getEndDate()).and("votingEndDate").is(jam.getVotingEndDate()), matchingClaim)); + Modjam claimed = mongoTemplate.findAndModify(claim, + new org.springframework.data.mongodb.core.query.Update().set("finalizationHash", finalizationHash).set("finalizationAwards", awards), + org.springframework.data.mongodb.core.FindAndModifyOptions.options().returnNew(true), Modjam.class); + if (claimed == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "This jam is already finalizing or completed. Refresh to see its winners."); + + calculateScores(jamId); + allSubs = submissionRepository.findByJamId(jamId); + if (allSubs == null) allSubs = new ArrayList<>(); + for (ModjamSubmission sub : allSubs) { + sub.setWinner(awards.containsKey(sub.getId())); + sub.setAwardTitle(awards.get(sub.getId())); + votePersistence.saveAward(sub); + } + + jam.setStatus("COMPLETED"); + jam.setWinnersAnnouncedAt(Instant.now()); + jam.setUpdatedAt(Instant.now()); + var persisted = mongoTemplate.updateFirst(Query.query(Criteria.where("_id").is(jamId).and("status").ne("COMPLETED").and("finalizationHash").is(finalizationHash)), + new org.springframework.data.mongodb.core.query.Update().set("status", "COMPLETED") + .set("updatedAt", jam.getUpdatedAt()).set("winnersAnnouncedAt", jam.getWinnersAnnouncedAt()), Modjam.class); + if (persisted.getMatchedCount() == 0) return enrichAndReturn(modjamRepository.findById(jamId).orElse(jam)); + Modjam saved = jam; + feedService.recordMilestones(saved); + return enrichAndReturn(saved); + } + + @Scheduled(fixedDelay = 60000) + public void updateJamStates() { + List jams = modjamRepository.findAll(); + Instant now = Instant.now(); + for (Modjam jam : jams) { + if ("DRAFT".equals(jam.getStatus())) continue; + if ("COMPLETED".equals(jam.getStatus())) { + revealHiddenJamProjects(jam.getId()); + continue; + } + + String newStatus = jam.getStatus(); + if (jam.getStartDate() != null && now.isBefore(jam.getStartDate())) { + newStatus = "UPCOMING"; + } else if (jam.getEndDate() != null && now.isBefore(jam.getEndDate())) { + newStatus = "ACTIVE"; + } else if (jam.getVotingEndDate() != null && now.isBefore(jam.getVotingEndDate())) { + newStatus = "VOTING"; + } else { + newStatus = "AWAITING_WINNERS"; + } + + if (!newStatus.equals(jam.getStatus())) { + String oldStatus = jam.getStatus(); + jam.setStatus(newStatus); + mongoTemplate.updateFirst(Query.query(Criteria.where("_id").is(jam.getId()).and("status").is(oldStatus).and("finalizationHash").exists(false)), + new org.springframework.data.mongodb.core.query.Update().set("status", newStatus), Modjam.class); + + } + // Retry release independently of a one-time phase transition. Review + // and scanning can complete after the jam enters its voting phase. + if (!ModjamPhase.hidesEntries(jam, now)) revealHiddenJamProjects(jam.getId()); + } + } + + @Scheduled(cron = "0 0 0 * * *") + public void cleanupStaleDrafts() { + Instant thirtyDaysAgo = Instant.now().minus(30, ChronoUnit.DAYS); + List staleDrafts = modjamRepository.findByStatusAndUpdatedAtBefore("DRAFT", thirtyDaysAgo); + for (Modjam jam : staleDrafts) { + submissionRepository.deleteAll(submissionRepository.findByJamId(jam.getId())); + modjamRepository.delete(jam); + } + } + + private void revealHiddenJamProjects(String jamId) { + Query query = new Query( + Criteria.where("modjamIds").is(jamId) + .and("modjamPublicationPending").is(true) + .and("status").is(ProjectStatus.PRIVATE) + .and("deletedAt").is(null) + ); + List hidden = mongoTemplate.find(query, Project.class); + for (Project project : hidden) { + lifecycleService.releaseModjamEmbargo(project); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java index 204fe5de0..6890f985a 100644 --- a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java @@ -9,6 +9,7 @@ import net.modtale.service.admin.audit.AdminAuditLogger; import net.modtale.service.analytics.ScoringService; import net.modtale.service.communication.NotificationService; +import net.modtale.service.jam.ModjamEmbargoService; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.lifecycle.ProjectDeletionService; import net.modtale.service.project.lifecycle.ProjectRetentionService; @@ -28,6 +29,7 @@ public class ProjectModerationService { private final ScanService scanService; private final ProjectVersionAccessService projectVersionAccessService; private final AdminAuditLogger adminAuditLogger; + private final ModjamEmbargoService modjamEmbargoService; public ProjectModerationService( ProjectRepository projectRepository, @@ -38,7 +40,8 @@ public ProjectModerationService( NotificationService notificationService, ScanService scanService, ProjectVersionAccessService projectVersionAccessService, - AdminAuditLogger adminAuditLogger + AdminAuditLogger adminAuditLogger, + ModjamEmbargoService modjamEmbargoService ) { this.projectRepository = projectRepository; this.projectService = projectService; @@ -49,6 +52,7 @@ public ProjectModerationService( this.scanService = scanService; this.projectVersionAccessService = projectVersionAccessService; this.adminAuditLogger = adminAuditLogger; + this.modjamEmbargoService = modjamEmbargoService; } public void deleteProject(net.modtale.model.user.User adminUser, String id, String reason) { @@ -85,7 +89,11 @@ public void restoreProject(net.modtale.model.user.User adminUser, String id, Pro public void unlistProject(net.modtale.model.user.User adminUser, String id, String reason) { Project targetProject = requireProject(id); + if (modjamEmbargoService.hasActiveEmbargo(targetProject)) { + throw new net.modtale.exception.InvalidProjectRequestException("This jam hides its entries until voting opens."); + } targetProject.setStatus(ProjectStatus.UNLISTED); + targetProject.setModjamPublicationPending(false); targetProject.setExpiresAt(null); scoringService.markProjectRankingDirty(targetProject); projectRepository.save(targetProject); diff --git a/backend/src/main/java/net/modtale/service/jam/DiscordFeedTransport.java b/backend/src/main/java/net/modtale/service/jam/DiscordFeedTransport.java new file mode 100644 index 000000000..3f1d17023 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/DiscordFeedTransport.java @@ -0,0 +1,13 @@ +package net.modtale.service.jam; + +import java.net.URI; +import java.time.Duration; +import java.util.Map; + +public interface DiscordFeedTransport { + Result send(URI destination, Map payload); + + record Result(int status, Duration retryAfter) { + public boolean accepted() { return status >= 200 && status < 300; } + } +} diff --git a/backend/src/main/java/net/modtale/service/jam/DiscordWebhookFeedTransport.java b/backend/src/main/java/net/modtale/service/jam/DiscordWebhookFeedTransport.java new file mode 100644 index 000000000..4f38f12fd --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/DiscordWebhookFeedTransport.java @@ -0,0 +1,55 @@ +package net.modtale.service.jam; + +import java.math.BigDecimal; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +@Component +public class DiscordWebhookFeedTransport implements DiscordFeedTransport { + private final RestTemplate rest; + + public DiscordWebhookFeedTransport() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(3)); + factory.setReadTimeout(Duration.ofSeconds(10)); + this.rest = new RestTemplate(factory); + } + + // A fake HTTP request factory can be injected by tests, without opening sockets. + DiscordWebhookFeedTransport(RestTemplate rest) { this.rest = rest; } + + @Override + public Result send(URI destination, Map payload) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + try { + var response = rest.postForEntity(destination, new HttpEntity<>(payload, headers), String.class); + return new Result(response.getStatusCode().value(), retryAfter(response.getHeaders())); + } catch (HttpStatusCodeException exception) { + // Return only status and retry timing. Exceptions may contain the secret URL. + return new Result(exception.getStatusCode().value(), retryAfter(exception.getResponseHeaders())); + } + } + + static Duration retryAfter(HttpHeaders headers) { + if (headers == null) return Duration.ZERO; + String value = headers.getFirst("Retry-After"); + if (value == null) value = headers.getFirst("X-RateLimit-Reset-After"); + try { + if (value == null) return Duration.ZERO; + long milliseconds = new BigDecimal(value).multiply(BigDecimal.valueOf(1000)) + .setScale(0, java.math.RoundingMode.CEILING).longValueExact(); + return milliseconds > 0 ? Duration.ofMillis(milliseconds) : Duration.ZERO; + } catch (NumberFormatException | ArithmeticException ignored) { + return Duration.ZERO; + } + } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamCustomizationService.java b/backend/src/main/java/net/modtale/service/jam/ModjamCustomizationService.java new file mode 100644 index 000000000..9681c2428 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamCustomizationService.java @@ -0,0 +1,141 @@ +package net.modtale.service.jam; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import net.modtale.model.jam.Modjam; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.project.ProjectVersion; +import net.modtale.service.project.catalog.GameVersionService; +import org.springframework.stereotype.Service; + +@Service +public class ModjamCustomizationService { + private static final Set SELECTORS = Set.of("h1", "h2", "h3", "p", "a", "blockquote", "ul", "ol", "li", "code", "pre", "table", "th", "td", "hr", "strong", "em"); + private static final Set PROPERTIES = Set.of("background-color", "color", "border-color", "border-radius", "font-size", "font-weight", "line-height", "letter-spacing", "padding", "margin", "text-align", "text-decoration"); + private static final Pattern RULE = Pattern.compile("([^{}]+)\\{([^{}]*)\\}"); + private static final Pattern UNSAFE_VALUE = Pattern.compile("\\b(?:url|expression|var|attr|env)\\s*\\(", Pattern.CASE_INSENSITIVE); + private final GameVersionService gameVersionService; + + public ModjamCustomizationService(GameVersionService gameVersionService) { + this.gameVersionService = gameVersionService; + } + + public void validate(Modjam jam) { + validateCss(jam.getCustomCss()); + if (jam.getSlug() == null || !jam.getSlug().matches("^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$")) { + throw new IllegalArgumentException("Jam URL must be 3–50 lowercase letters, numbers or dashes, without a leading or trailing dash."); + } + if (jam.getTitle() == null || jam.getTitle().trim().length() < 5 || jam.getTitle().length() > 150) { + throw new IllegalArgumentException("Jam title must be 5–150 characters."); + } + if (jam.getStatus() == null || !Set.of("DRAFT", "UPCOMING", "ACTIVE", "VOTING", "AWAITING_WINNERS", "COMPLETED").contains(jam.getStatus())) { + throw new IllegalArgumentException("Invalid jam status."); + } + if (!"DRAFT".equals(jam.getStatus())) { + if (jam.getDescription() == null || jam.getDescription().trim().length() < 10 + || jam.getStartDate() == null || jam.getEndDate() == null || jam.getVotingEndDate() == null + || !jam.getStartDate().isBefore(jam.getEndDate()) || !jam.getEndDate().isBefore(jam.getVotingEndDate()) + || jam.getCategories() == null || jam.getCategories().isEmpty()) { + throw new IllegalArgumentException("Published jams need a description, ordered start/submission/voting dates and scoring criteria."); + } + } + Set categoryIds = new HashSet<>(); + if (jam.getCategories() != null) { + if (jam.getCategories().size() > 20) throw new IllegalArgumentException("A jam supports up to 20 scoring criteria."); + for (Modjam.Category category : jam.getCategories()) { + if (category == null || category.getName() == null || category.getName().isBlank() || category.getName().length() > 100 + || category.getMaxScore() < 1 || category.getMaxScore() > 100) { + throw new IllegalArgumentException("Scoring criteria need a name and a maximum score between 1 and 100."); + } + if (category.getId() != null && !category.getId().isBlank() && !categoryIds.add(category.getId())) { + throw new IllegalArgumentException("Scoring criterion IDs must be unique."); + } + } + } + validateVersionRestriction(jam.getRestrictions()); + } + + public void validateVersionRestriction(Modjam.Restrictions restrictions) { + if (restrictions == null) return; + List exact = restrictions.getAllowedGameVersions(); + String minimum = restrictions.getMinimumGameVersion(); + String maximum = restrictions.getMaximumGameVersion(); + boolean range = present(minimum) || present(maximum); + if ((exact == null || exact.isEmpty()) && !range) return; + List catalog = gameVersionService.getCatalog().allVersions(); + if (exact != null && !exact.isEmpty()) { + if (range) throw new IllegalArgumentException("Choose specific game versions or a range, not both."); + if (exact.size() > 100 || exact.stream().anyMatch(version -> version == null || !catalog.contains(version))) { + throw new IllegalArgumentException("Choose supported game versions from the catalog."); + } + } + if (present(minimum) && !catalog.contains(minimum) || present(maximum) && !catalog.contains(maximum)) { + throw new IllegalArgumentException("Game version range endpoints must exist in the catalog."); + } + if (present(minimum) && present(maximum) && catalog.indexOf(minimum) < catalog.indexOf(maximum)) { + throw new IllegalArgumentException("Minimum game version must not be newer than maximum game version."); + } + } + + public void validateProjectVersions(Modjam.Restrictions restrictions, Project project) { + if (restrictions == null) return; + List exact = restrictions.getAllowedGameVersions(); + boolean range = present(restrictions.getMinimumGameVersion()) || present(restrictions.getMaximumGameVersion()); + if ((exact == null || exact.isEmpty()) && !range) return; + validateVersionRestriction(restrictions); + List catalog = gameVersionService.getCatalog().allVersions(); + boolean reviewing = project.getStatus() == ProjectStatus.DRAFT || project.getStatus() == ProjectStatus.PENDING; + boolean compatible = project.getVersions() != null && project.getVersions().stream() + .filter(version -> version != null && present(version.getFileUrl())) + .filter(version -> version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED + || reviewing && version.getReviewStatus() == ProjectVersion.ReviewStatus.PENDING) + .filter(version -> version.getGameVersions() != null) + .flatMap(version -> version.getGameVersions().stream()) + .anyMatch(version -> range ? inRange(version, restrictions, catalog) : exact.contains(version)); + if (!compatible) throw new IllegalArgumentException("Project needs an eligible uploaded release supporting a required game version."); + } + + private boolean inRange(String version, Modjam.Restrictions restrictions, List catalog) { + int index = catalog.indexOf(version); + return index >= 0 + && (!present(restrictions.getMinimumGameVersion()) || index <= catalog.indexOf(restrictions.getMinimumGameVersion())) + && (!present(restrictions.getMaximumGameVersion()) || index >= catalog.indexOf(restrictions.getMaximumGameVersion())); + } + + public static void validateCss(String css) { + if (css == null || css.isBlank()) return; + if (css.length() > 10000 || Pattern.compile("[@\\\\<>\"'!]").matcher(css).find()) { + throw new IllegalArgumentException("Custom CSS cannot contain imports, escapes or unsafe syntax."); + } + String input = css.replaceAll("(?s)/\\*.*?\\*/", "").trim(); + var matcher = RULE.matcher(input); + int end = 0; + boolean found = false; + while (matcher.find()) { + found = true; + if (!input.substring(end, matcher.start()).isBlank()) throw invalidCss(); + for (String selector : matcher.group(1).split(",", -1)) { + if (!SELECTORS.contains(selector.trim())) throw invalidCss(); + } + for (String declaration : matcher.group(2).split(";")) { + if (declaration.isBlank()) continue; + String[] parts = declaration.split(":", -1); + if (parts.length != 2 || !PROPERTIES.contains(parts[0].trim())) throw invalidCss(); + String value = parts[1].trim(); + if (value.isEmpty() || !value.matches("[a-zA-Z0-9#.%(),\\s-]+") || UNSAFE_VALUE.matcher(value).find() + || (value.contains("(") || value.contains(")")) && !value.matches("(?i)(rgb|rgba|hsl|hsla)\\([\\d.%\\s,/-]+\\)")) throw invalidCss(); + } + end = matcher.end(); + } + if (!found || !input.substring(end).isBlank()) throw invalidCss(); + } + + private static IllegalArgumentException invalidCss() { + return new IllegalArgumentException("Use simple Markdown element selectors and typography/color/spacing declarations in custom CSS."); + } + + private static boolean present(String value) { return value != null && !value.isBlank(); } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamDiscordFeedService.java b/backend/src/main/java/net/modtale/service/jam/ModjamDiscordFeedService.java new file mode 100644 index 000000000..4ea147d93 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamDiscordFeedService.java @@ -0,0 +1,230 @@ +package net.modtale.service.jam; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.config.properties.AppModjamDiscordFeedProperties; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamDiscordEvent; +import net.modtale.model.jam.ModjamDiscordEvent.Milestone; +import net.modtale.repository.jam.ModjamDiscordOutboxStore; +import net.modtale.repository.jam.ModjamRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +public class ModjamDiscordFeedService { + private static final Logger LOG = LoggerFactory.getLogger(ModjamDiscordFeedService.class); + private static final List PUBLIC_STATUSES = + List.of("UPCOMING", "ACTIVE", "VOTING", "AWAITING_WINNERS", "COMPLETED"); + private static final Duration LEASE = Duration.ofMinutes(2); + private static final int BATCH_SIZE = 20; + private final ModjamRepository jams; + private final ModjamDiscordOutboxStore outbox; + private final DiscordFeedTransport transport; + private final AppModjamDiscordFeedProperties properties; + private final String frontendUrl; + private final Clock clock; + private final Instant activationRequestedAt; + private volatile Instant activatedAt; + + @Autowired + public ModjamDiscordFeedService(ModjamRepository jams, ModjamDiscordOutboxStore outbox, + DiscordFeedTransport transport, AppModjamDiscordFeedProperties properties, + AppFrontendProperties frontend) { + this(jams, outbox, transport, properties, frontend, Clock.systemUTC()); + } + + ModjamDiscordFeedService(ModjamRepository jams, ModjamDiscordOutboxStore outbox, + DiscordFeedTransport transport, AppModjamDiscordFeedProperties properties, + AppFrontendProperties frontend, Clock clock) { + this.jams = jams; + this.outbox = outbox; + this.transport = transport; + this.properties = properties; + this.frontendUrl = frontend.url(); + this.clock = clock; + this.activationRequestedAt = clock.instant(); + if (destination().isPresent()) { + try { + activatedAt = outbox.activationTime(activationRequestedAt); + } catch (RuntimeException exception) { + LOG.warn("Modjam Discord feed activation deferred"); + } + } + } + + /** Call only after the jam is saved. The scheduled reconciliation repairs missed enqueues. */ + public void recordMilestones(Modjam jam) { + if (destination().isEmpty() || !publicJam(jam)) return; + Instant now = clock.instant(); + try { + if (activatedAt == null) activatedAt = outbox.activationTime(activationRequestedAt); + enqueue(jam, Milestone.CREATED, + jam.getPublishedAt() == null ? jam.getCreatedAt() : jam.getPublishedAt(), now); + if (validSubmissionWindow(jam) && !now.isBefore(jam.getStartDate())) { + enqueue(jam, Milestone.SUBMISSIONS_OPENED, jam.getStartDate(), now); + } + // Concurrent voting starts with submissions, so it has no separate announcement. + if (separateVotingWindow(jam) && !now.isBefore(jam.getEndDate())) { + enqueue(jam, Milestone.VOTING_OPENED, jam.getEndDate(), now); + } + // An elapsed voting date never announces winners. Only persisted finalization does. + if ("COMPLETED".equals(jam.getStatus())) { + enqueue(jam, Milestone.WINNERS_ANNOUNCED, jam.getWinnersAnnouncedAt(), now); + } + } catch (RuntimeException exception) { + // Keep a successful lifecycle action independent of feed storage/delivery failure. + LOG.warn("Modjam Discord feed enqueue deferred for jam {}", jam.getId()); + } + } + + @Scheduled(fixedDelay = 60000) + public void reconcileMilestones() { + if (destination().isEmpty()) return; + try { + jams.findByStatusIn(PUBLIC_STATUSES).forEach(this::recordMilestones); + } catch (RuntimeException exception) { + LOG.warn("Modjam Discord feed reconciliation deferred"); + } + } + + @Scheduled(fixedDelay = 15000) + public void deliverPending() { + Optional destination = destination(); + if (destination.isEmpty()) return; + for (int i = 0; i < BATCH_SIZE; i++) { + ModjamDiscordEvent event; + try { + event = outbox.claimNext(clock.instant(), LEASE).orElse(null); + } catch (RuntimeException exception) { + LOG.warn("Modjam Discord feed outbox temporarily unavailable"); + return; + } + if (event == null) return; + try { + Optional current = jams.findById(event.getJamId()); + if (current.isEmpty()) { + outbox.discard(event); + continue; + } + Modjam jam = current.get(); + // Re-read before every send: an unpublished jam must never leak queued data. + if (!publicJam(jam) || !stillApplicable(event, jam, clock.instant())) { + outbox.retry(event, clock.instant().plus(Duration.ofMinutes(1)), null); + continue; + } + DiscordFeedTransport.Result result = transport.send(destination.get(), payload(jam, event)); + if (result.accepted()) { + // Discord offers no webhook idempotency key. A process crash after HTTP + // acceptance but before this write can replay the message; delivery is at-least-once. + outbox.delivered(event, clock.instant()); + } else { + retry(event, result.status(), result.retryAfter()); + // A webhook's rate limit is shared by every event, not just this milestone. + if (result.status() == 429) { + Duration pause = result.retryAfter() == null ? Duration.ZERO : result.retryAfter(); + if (pause.compareTo(Duration.ofSeconds(30)) < 0) pause = Duration.ofSeconds(30); + outbox.pauseUntil(clock.instant().plus(pause)); + return; + } + } + } catch (RuntimeException exception) { + // Do not log HTTP exception text: it can contain the credential-bearing URL. + LOG.warn("Modjam Discord feed delivery deferred for event {}", event.getId()); + try { + retry(event, null, Duration.ZERO); + } catch (RuntimeException ignored) { + // The durable lease expires and another pass retries after a DB outage. + return; + } + } + } + } + + private void enqueue(Modjam jam, Milestone milestone, Instant occurredAt, Instant now) { + // Never backfill pre-activation history. The durable cutoff still repairs downtime gaps. + if (occurredAt == null || activatedAt == null || occurredAt.isBefore(activatedAt) || occurredAt.isAfter(now)) return; + outbox.enqueue(new ModjamDiscordEvent(jam.getId(), milestone, occurredAt, now)); + } + + private void retry(ModjamDiscordEvent event, Integer status, Duration retryAfter) { + // Retry indefinitely with a bounded exponential delay, including recoverable config errors. + long seconds = Math.min(21600, 30L << Math.min(10, Math.max(0, event.getAttempts() - 1))); + Duration delay = Duration.ofSeconds(seconds); + if (retryAfter != null && retryAfter.compareTo(delay) > 0) delay = retryAfter; + outbox.retry(event, clock.instant().plus(delay), status); + } + + private static boolean publicJam(Modjam jam) { + return jam != null && jam.getId() != null && jam.getStatus() != null && PUBLIC_STATUSES.contains(jam.getStatus()); + } + + private static boolean validSubmissionWindow(Modjam jam) { + return jam.getStartDate() != null && jam.getEndDate() != null + && jam.getEndDate().isAfter(jam.getStartDate()); + } + + private static boolean separateVotingWindow(Modjam jam) { + return !jam.isAllowConcurrentVoting() && validSubmissionWindow(jam) + && jam.getVotingEndDate() != null && jam.getVotingEndDate().isAfter(jam.getEndDate()); + } + + private static boolean stillApplicable(ModjamDiscordEvent event, Modjam jam, Instant now) { + return switch (event.getMilestone()) { + case CREATED -> true; + case SUBMISSIONS_OPENED -> validSubmissionWindow(jam) && !now.isBefore(jam.getStartDate()); + case VOTING_OPENED -> separateVotingWindow(jam) && !now.isBefore(jam.getEndDate()); + case WINNERS_ANNOUNCED -> "COMPLETED".equals(jam.getStatus()) && jam.getWinnersAnnouncedAt() != null + && !now.isBefore(jam.getWinnersAnnouncedAt()); + }; + } + + private Map payload(Modjam jam, ModjamDiscordEvent event) { + String action = switch (event.getMilestone()) { + case CREATED -> "New modjam"; + case SUBMISSIONS_OPENED -> "Submissions opened"; + case VOTING_OPENED -> "Voting opened"; + case WINNERS_ANNOUNCED -> "Winners announced"; + }; + String title = jam.getTitle() == null ? "Modjam" : jam.getTitle() + .replaceAll("[\\p{Cntrl}\\r\\n]+", " ").replace("@", "@\u200B") + .replace("<", "").replace(">", "").replace("`", ""); + title = title.substring(0, Math.min(240, title.length())); + String base = frontendUrl == null ? "https://modtale.net" : frontendUrl.replaceAll("/+$", ""); + String slug = jam.getSlug(); + if (slug == null || !slug.matches("[a-z0-9-]{1,50}")) slug = ""; + String link = slug.isEmpty() ? base + "/jams" : base + "/jam/" + slug; + String content = action + ": " + title + "\n" + link; + if (content.length() > 1900) content = content.substring(0, 1900); + return Map.of("content", content, "allowed_mentions", + Map.of("parse", List.of(), "users", List.of(), "roles", List.of(), "replied_user", false)); + } + + private Optional destination() { + if (!properties.enabled() || properties.url() == null || properties.url().isBlank()) return Optional.empty(); + try { + URI uri = URI.create(properties.url()); + if (!"https".equals(uri.getScheme()) || uri.getUserInfo() != null || uri.getFragment() != null + || uri.getPort() != -1 && uri.getPort() != 443 + || uri.getHost() == null + || !List.of("discord.com", "canary.discord.com", "ptb.discord.com", "discordapp.com").contains(uri.getHost()) + || !uri.getPath().matches("/api(?:/v[0-9]+)?/webhooks/[0-9]+/[A-Za-z0-9._-]+")) { + return Optional.empty(); + } + // wait=true asks Discord to confirm message persistence before acknowledging delivery. + return Optional.of(UriComponentsBuilder.fromUri(uri).replaceQueryParam("wait", "true").build(true).toUri()); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamEmbargoService.java b/backend/src/main/java/net/modtale/service/jam/ModjamEmbargoService.java new file mode 100644 index 000000000..ee7b79b55 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamEmbargoService.java @@ -0,0 +1,31 @@ +package net.modtale.service.jam; + +import java.time.Instant; +import java.util.HashSet; +import net.modtale.model.project.Project; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.jam.ModjamSubmissionRepository; +import org.springframework.stereotype.Service; + +@Service +public class ModjamEmbargoService { + private final ModjamRepository jamRepository; + private final ModjamSubmissionRepository submissionRepository; + + public ModjamEmbargoService(ModjamRepository jamRepository, ModjamSubmissionRepository submissionRepository) { + this.jamRepository = jamRepository; + this.submissionRepository = submissionRepository; + } + + public boolean hasActiveEmbargo(Project project) { + var ids = new HashSet(); + if (project.getModjamIds() != null) ids.addAll(project.getModjamIds()); + if (project.getId() != null) { + var submissions = submissionRepository.findByProjectId(project.getId()); + if (submissions != null) submissions.forEach(submission -> ids.add(submission.getJamId())); + } + Instant now = Instant.now(); + return ids.stream().filter(id -> id != null).anyMatch(id -> jamRepository.findById(id) + .map(jam -> ModjamPhase.hidesEntries(jam, now)).orElse(false)); + } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamMembershipPersistence.java b/backend/src/main/java/net/modtale/service/jam/ModjamMembershipPersistence.java new file mode 100644 index 000000000..8a8389f26 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamMembershipPersistence.java @@ -0,0 +1,92 @@ +package net.modtale.service.jam; + +import java.util.ArrayList; +import java.util.List; +import net.modtale.model.jam.Modjam; +import net.modtale.model.user.User; +import org.bson.Document; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; + +@Service +public class ModjamMembershipPersistence { + private final MongoTemplate mongo; + + public ModjamMembershipPersistence(MongoTemplate mongo) { + this.mongo = mongo; + } + + public Modjam participate(String jamId, String userId, boolean join) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(jamId).and("participantIds").is(null)), + new Update().set("participantIds", List.of()), Modjam.class); + Update jamUpdate = join ? new Update().addToSet("participantIds", userId) : new Update().pull("participantIds", userId); + Modjam jam = mongo.findAndModify(Query.query(Criteria.where("_id").is(jamId)), + jamUpdate, FindAndModifyOptions.options().returnNew(true), Modjam.class); + if (jam != null) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(userId).and("deletedAt").is(null).and("joinedModjamIds").is(null)), + new Update().set("joinedModjamIds", List.of()), User.class); + // Always reconcile both sides. A repeated request repairs an interrupted + // first write without duplicating memberships or replacing an account. + Update userUpdate = join ? new Update().addToSet("joinedModjamIds", jamId) : new Update().pull("joinedModjamIds", jamId); + mongo.updateFirst(Query.query(Criteria.where("_id").is(userId).and("deletedAt").is(null)), userUpdate, User.class); + } + return jam; + } + + public Modjam inviteJudge(String jamId, String userId, String username) { + Object entries = inviteEntries(); + Object keys = new Document("$map", new Document("input", entries).append("as", "invite").append("in", "$$invite.k")); + Document eligible = new Document("$and", List.of( + new Document("$not", List.of(new Document("$in", List.of(literal(userId), array("judgeIds"))))), + new Document("$not", List.of(new Document("$in", List.of(literal(userId), keys)))))); + Object invites = new Document("$arrayToObject", new Document("$concatArrays", List.of(entries, + literal(List.of(new Document("k", userId).append("v", username)))))); + return changeJudges(jamId, eligible, new Document("pendingJudgeInviteUsers", invites)); + } + + public Modjam answerJudgeInvite(String jamId, String userId, boolean accept) { + Object entries = inviteEntries(); + Object keys = new Document("$map", new Document("input", entries).append("as", "invite").append("in", "$$invite.k")); + Document eligible = new Document("$in", List.of(literal(userId), keys)); + Document fields = new Document("pendingJudgeInviteUsers", withoutInvites(List.of(userId))); + if (accept) fields.append("judgeIds", new Document("$setUnion", List.of(array("judgeIds"), literal(List.of(userId))))); + return changeJudges(jamId, eligible, fields); + } + + public Modjam removeJudges(String jamId, List userIds) { + Document fields = new Document("pendingJudgeInviteUsers", withoutInvites(userIds)) + .append("judgeIds", new Document("$setDifference", List.of(array("judgeIds"), literal(userIds)))); + return changeJudges(jamId, null, fields); + } + + private Modjam changeJudges(String jamId, Document guard, Document fields) { + Query query = Query.query(Criteria.where("_id").is(jamId)); + if (guard != null) query.addCriteria(Criteria.where("$expr").is(guard)); + List stages = new ArrayList<>(); + stages.add(context -> new Document("$set", fields)); + // The display list is derived from the updated identity map, rather than + // restoring a stale list that could erase a concurrently accepted invite. + stages.add(context -> new Document("$set", new Document("pendingJudgeInvites", + new Document("$map", new Document("input", inviteEntries()).append("as", "invite").append("in", "$$invite.v"))))); + return mongo.findAndModify(query, AggregationUpdate.from(stages), + FindAndModifyOptions.options().returnNew(true), Modjam.class); + } + + private static Object withoutInvites(List userIds) { + return new Document("$arrayToObject", new Document("$filter", new Document("input", inviteEntries()) + .append("as", "invite").append("cond", new Document("$not", List.of( + new Document("$in", List.of("$$invite.k", literal(userIds)))))))); + } + + private static Object inviteEntries() { + return new Document("$objectToArray", new Document("$ifNull", List.of("$pendingJudgeInviteUsers", new Document()))); + } + + private static Object array(String name) { return new Document("$ifNull", List.of("$" + name, List.of())); } + private static Object literal(Object value) { return new Document("$literal", value); } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamOrganizerService.java b/backend/src/main/java/net/modtale/service/jam/ModjamOrganizerService.java new file mode 100644 index 000000000..3335cb439 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamOrganizerService.java @@ -0,0 +1,158 @@ +package net.modtale.service.jam; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.Modjam.JamPermission; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.user.UserRepository; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class ModjamOrganizerService { + private final ModjamRepository jams; + private final UserRepository users; + private final MongoTemplate mongo; + + public ModjamOrganizerService(ModjamRepository jams, UserRepository users, MongoTemplate mongo) { + this.jams = jams; + this.users = users; + this.mongo = mongo; + } + + public boolean permits(Modjam jam, String userId, JamPermission permission) { + if (userId == null) return false; + if (Objects.equals(jam.getHostId(), userId)) return true; + if (jam.getOrganizerMembers() == null || jam.getOrganizerRoles() == null) return false; + return jam.getOrganizerMembers().stream().filter(Objects::nonNull) + .filter(member -> userId.equals(member.userId())) + .anyMatch(member -> jam.getOrganizerRoles().stream().filter(Objects::nonNull) + .anyMatch(role -> Objects.equals(role.id(), member.roleId()) + && role.permissions() != null && role.permissions().contains(permission))); + } + + public boolean isOrganizer(Modjam jam, String userId) { + return userId != null && (Objects.equals(jam.getHostId(), userId) + || jam.getOrganizerMembers() != null && jam.getOrganizerMembers().stream().filter(Objects::nonNull) + .anyMatch(member -> userId.equals(member.userId()))); + } + + public void require(Modjam jam, String userId, JamPermission permission) { + if (!permits(jam, userId, permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Your organizer role does not allow this action."); + } + } + + public Modjam saveRole(String jamId, String userId, Modjam.OrganizerRole input) { + Modjam jam = owned(jamId, userId); + if (input == null || input.name() == null || input.name().isBlank() || input.name().length() > 50 + || input.color() == null || !input.color().matches("^#[a-fA-F0-9]{6}$") + || input.permissions() == null || input.permissions().isEmpty() || input.permissions().stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("Roles need a name, a six-digit color and at least one permission."); + } + List roles = mutable(jam.getOrganizerRoles()); + String id = input.id() == null || input.id().isBlank() ? UUID.randomUUID().toString() : input.id(); + if (input.id() != null && !input.id().isBlank() && roles.stream().noneMatch(role -> id.equals(role.id()))) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Organizer role not found."); + } + roles.removeIf(role -> id.equals(role.id())); + if (roles.size() >= 20) throw new IllegalArgumentException("A jam supports up to 20 organizer roles."); + roles.add(new Modjam.OrganizerRole(id, input.name().trim(), input.color(), Set.copyOf(input.permissions()))); + jam.setOrganizerRoles(roles); + persist(jam, "organizerRoles", roles); + return jam; + } + + public Modjam deleteRole(String jamId, String userId, String roleId) { + Modjam jam = owned(jamId, userId); + if (jam.getOrganizerMembers() != null && jam.getOrganizerMembers().stream().anyMatch(member -> roleId.equals(member.roleId())) + || jam.getPendingOrganizerInvites() != null && jam.getPendingOrganizerInvites().stream().anyMatch(invite -> roleId.equals(invite.roleId()))) { + throw new IllegalArgumentException("Remove this role's members and invitations before deleting it."); + } + List roles = mutable(jam.getOrganizerRoles()); + if (!roles.removeIf(role -> roleId.equals(role.id()))) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Organizer role not found."); + jam.setOrganizerRoles(roles); + persist(jam, "organizerRoles", roles); + return jam; + } + + public Modjam invite(String jamId, String hostId, String username, String roleId) { + Modjam jam = owned(jamId, hostId); + if (jam.getOrganizerRoles() == null || jam.getOrganizerRoles().stream().noneMatch(role -> Objects.equals(role.id(), roleId))) { + throw new IllegalArgumentException("Choose an existing organizer role."); + } + if (username == null || username.isBlank() || username.length() > 50) throw new IllegalArgumentException("Choose a username."); + User user = users.findByUsernameIgnoreCase(username.trim()).orElseThrow(() -> new IllegalArgumentException("User not found.")); + if (Objects.equals(user.getId(), hostId) || isOrganizer(jam, user.getId())) throw new IllegalArgumentException("User is already an organizer."); + List invites = mutable(jam.getPendingOrganizerInvites()); + if (invites.stream().anyMatch(invite -> user.getId().equals(invite.userId()))) throw new IllegalArgumentException("User is already invited."); + if (invites.size() >= 50) throw new IllegalArgumentException("A jam supports up to 50 pending organizer invitations."); + invites.add(new Modjam.OrganizerInvite(user.getId(), user.getUsername(), roleId)); + jam.setPendingOrganizerInvites(invites); + var persisted = mongo.updateFirst(Query.query(Criteria.where("_id").is(jamId).and("pendingOrganizerInvites.userId").ne(user.getId())), + new Update().addToSet("pendingOrganizerInvites", invites.getLast()).set("updatedAt", java.time.Instant.now()), Modjam.class); + if (persisted.getMatchedCount() == 0) throw new ResponseStatusException(HttpStatus.CONFLICT, "This invitation changed. Refresh the jam and try again."); + org.bson.Document notification = new org.bson.Document("userId", user.getId()) + .append("title", "Jam organizer invitation") + .append("message", "You have been invited to organize " + jam.getTitle()) + .append("link", "/jam/" + jam.getSlug() + "/overview") + .append("read", false).append("createdAt", java.time.Instant.now()); + mongo.save(notification, "notifications"); + return jam; + } + + public Modjam answerInvite(String jamId, String userId, boolean accept) { + Modjam jam = find(jamId); + List invites = mutable(jam.getPendingOrganizerInvites()); + Modjam.OrganizerInvite invite = invites.stream().filter(value -> Objects.equals(userId, value.userId())).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "No pending organizer invitation.")); + if (accept && (jam.getOrganizerRoles() == null || jam.getOrganizerRoles().stream().noneMatch(role -> Objects.equals(role.id(), invite.roleId())))) { + throw new IllegalArgumentException("The invited role no longer exists."); + } + invites.removeIf(value -> Objects.equals(userId, value.userId())); + List members = mutable(jam.getOrganizerMembers()); + if (accept && members.stream().noneMatch(member -> Objects.equals(userId, member.userId()))) members.add(new Modjam.OrganizerMember(userId, invite.roleId())); + jam.setPendingOrganizerInvites(invites); + jam.setOrganizerMembers(members); + Update mutation = new Update().pull("pendingOrganizerInvites", new org.bson.Document("userId", userId)).set("updatedAt", java.time.Instant.now()); + if (accept) mutation.addToSet("organizerMembers", new Modjam.OrganizerMember(userId, invite.roleId())); + var persisted = mongo.updateFirst(Query.query(Criteria.where("_id").is(jamId).and("pendingOrganizerInvites.userId").is(userId)), mutation, Modjam.class); + if (persisted.getMatchedCount() == 0) throw new ResponseStatusException(HttpStatus.CONFLICT, "This invitation is no longer pending."); + return jam; + } + + public Modjam remove(String jamId, String hostId, String userId) { + Modjam jam = owned(jamId, hostId); + if (Objects.equals(hostId, userId)) throw new IllegalArgumentException("The host cannot be removed."); + List members = mutable(jam.getOrganizerMembers()); + List invites = mutable(jam.getPendingOrganizerInvites()); + members.removeIf(member -> Objects.equals(userId, member.userId())); + invites.removeIf(invite -> Objects.equals(userId, invite.userId())); + jam.setOrganizerMembers(members); + jam.setPendingOrganizerInvites(invites); + mongo.updateFirst(Query.query(Criteria.where("_id").is(jamId)), + new Update().pull("organizerMembers", new org.bson.Document("userId", userId)) + .pull("pendingOrganizerInvites", new org.bson.Document("userId", userId)).set("updatedAt", java.time.Instant.now()), Modjam.class); + return jam; + } + + private Modjam owned(String id, String userId) { + Modjam jam = find(id); + if (!Objects.equals(jam.getHostId(), userId)) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the host can manage organizer roles."); + return jam; + } + + private Modjam find(String id) { return jams.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Jam not found.")); } + private void persist(Modjam jam, String field, Object value) { mongo.updateFirst(Query.query(Criteria.where("_id").is(jam.getId())), new Update().set(field, value).set("updatedAt", java.time.Instant.now()), Modjam.class); } + private static List mutable(List values) { return values == null ? new ArrayList<>() : new ArrayList<>(values); } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamPhase.java b/backend/src/main/java/net/modtale/service/jam/ModjamPhase.java new file mode 100644 index 000000000..5a614336d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamPhase.java @@ -0,0 +1,22 @@ +package net.modtale.service.jam; + +import java.time.Instant; +import java.util.Set; +import net.modtale.model.jam.Modjam; + +public final class ModjamPhase { + private ModjamPhase() {} + + public static String current(Modjam jam, Instant now) { + if ("DRAFT".equals(jam.getStatus()) || "COMPLETED".equals(jam.getStatus())) return jam.getStatus(); + if (jam.getStartDate() != null && now.isBefore(jam.getStartDate())) return "UPCOMING"; + if (jam.getEndDate() != null && now.isBefore(jam.getEndDate())) return "ACTIVE"; + if (jam.getVotingEndDate() != null && now.isBefore(jam.getVotingEndDate())) return "VOTING"; + if (jam.getEndDate() != null || jam.getVotingEndDate() != null) return "AWAITING_WINNERS"; + return jam.getStatus() == null ? "" : jam.getStatus(); + } + + public static boolean hidesEntries(Modjam jam, Instant now) { + return jam.isHideSubmissions() && Set.of("DRAFT", "UPCOMING", "ACTIVE").contains(current(jam, now)); + } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamProjectReleasePersistence.java b/backend/src/main/java/net/modtale/service/jam/ModjamProjectReleasePersistence.java new file mode 100644 index 000000000..262491119 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamProjectReleasePersistence.java @@ -0,0 +1,46 @@ +package net.modtale.service.jam; + +import java.time.LocalDateTime; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.project.ScanStatus; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; + +@Service +public class ModjamProjectReleasePersistence { + private final MongoTemplate mongo; + + public ModjamProjectReleasePersistence(MongoTemplate mongo) { + this.mongo = mongo; + } + + public Project claimRelease(Project snapshot) { + Criteria guard = Criteria.where("_id").is(snapshot.getId()) + .and("status").is(ProjectStatus.PRIVATE) + .and("modjamPublicationPending").is(true) + .and("deletedAt").is(null) + .and("updatedAt").is(snapshot.getUpdatedAt()) + .and("classification").is(snapshot.getClassification()) + .and("modjamIds").is(snapshot.getModjamIds()); + Criteria noScan = Criteria.where("versions").not().elemMatch(Criteria.where("scanResult.status").is(ScanStatus.SCANNING)); + Criteria conditions = snapshot.getClassification() == ProjectClassification.MODPACK + ? new Criteria().andOperator(guard, noScan) + : new Criteria().andOperator(guard, noScan, Criteria.where("versions").elemMatch( + Criteria.where("reviewStatus").is(ProjectVersion.ReviewStatus.APPROVED))); + Update change = new Update().set("status", ProjectStatus.PUBLISHED) + .set("modjamPublicationPending", false) + .set("updatedAt", LocalDateTime.now().toString()) + .set("rankingDirty", true); + // Claim once and change only publication fields. A canceled private choice, + // changed project snapshot or second scheduler instance cannot win this claim. + return mongo.findAndModify(Query.query(conditions), change, + FindAndModifyOptions.options().returnNew(true), Project.class); + } +} diff --git a/backend/src/main/java/net/modtale/service/jam/ModjamVotePersistence.java b/backend/src/main/java/net/modtale/service/jam/ModjamVotePersistence.java new file mode 100644 index 000000000..435100abf --- /dev/null +++ b/backend/src/main/java/net/modtale/service/jam/ModjamVotePersistence.java @@ -0,0 +1,63 @@ +package net.modtale.service.jam; + +import java.util.List; +import net.modtale.model.jam.ModjamSubmission; +import org.bson.Document; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; + +@Service +public class ModjamVotePersistence { + private final MongoTemplate mongo; + + public ModjamVotePersistence(MongoTemplate mongo) { + this.mongo = mongo; + } + + public ModjamSubmission replaceBallot(String jamId, String submissionId, ModjamSubmission.Vote vote) { + Document ballot = new Document("_id", vote.getId()) + .append("voterId", vote.getVoterId()).append("categoryId", vote.getCategoryId()) + .append("score", vote.getScore()).append("isJudge", vote.isJudge()); + Document remaining = new Document("$filter", new Document("input", + new Document("$ifNull", List.of("$votes", List.of()))) + .append("as", "ballot") + .append("cond", new Document("$not", List.of(new Document("$and", List.of( + new Document("$eq", List.of("$$ballot.voterId", new Document("$literal", vote.getVoterId()))), + new Document("$eq", List.of("$$ballot.categoryId", new Document("$literal", vote.getCategoryId()))) + )))))); + Document fields = new Document("votes", new Document("$concatArrays", List.of(remaining, + new Document("$literal", List.of(ballot))))) + .append("voteRevision", new Document("$add", List.of(new Document("$ifNull", List.of("$voteRevision", 0)), 1))); + AggregationUpdate update = AggregationUpdate.from(List.of(context -> new Document("$set", fields))); + return mongo.findAndModify(Query.query(Criteria.where("_id").is(submissionId).and("jamId").is(jamId)), + update, FindAndModifyOptions.options().returnNew(true), ModjamSubmission.class); + } + + public void saveScores(ModjamSubmission submission) { + Criteria revision = submission.getVoteRevision() == 0 + ? new Criteria().orOperator(Criteria.where("voteRevision").is(0), Criteria.where("voteRevision").exists(false)) + : Criteria.where("voteRevision").is(submission.getVoteRevision()); + Query query = Query.query(new Criteria().andOperator( + Criteria.where("_id").is(submission.getId()).and("jamId").is(submission.getJamId()), revision)); + Update update = new Update().set("categoryScores", submission.getCategoryScores()) + .set("totalScore", submission.getTotalScore()) + .set("judgeCategoryScores", submission.getJudgeCategoryScores()) + .set("totalJudgeScore", submission.getTotalJudgeScore()) + .set("totalPublicScore", submission.getTotalPublicScore()) + .set("rank", submission.getRank()); + // If a ballot changed during calculation, this stale snapshot cannot + // overwrite its fresh scores or any ballot/award/project metadata. + mongo.updateFirst(query, update, ModjamSubmission.class); + } + + public void saveAward(ModjamSubmission submission) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(submission.getId()).and("jamId").is(submission.getJamId())), + new Update().set("isWinner", submission.isWinner()).set("awardTitle", submission.getAwardTitle()), + ModjamSubmission.class); + } +} diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java index b509a32ca..a27f96fac 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java @@ -36,6 +36,14 @@ public void submitProject(String id, User user) { projectDraftWorkflowService.submitProject(id, user); } + public void submitProjectForModjam(String id, User user, String jamId) { + projectDraftWorkflowService.submitProjectForModjam(id, user, jamId); + } + + public void releaseModjamEmbargo(Project project) { + projectPublicationService.releaseModjamEmbargo(project); + } + public void revertProjectToDraft(String id, User user) { projectPublicationService.revertProjectToDraft(id, user); } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java index 0fdba484f..4ff1158e7 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java @@ -9,6 +9,7 @@ import net.modtale.model.project.ProjectVersion; import net.modtale.repository.project.ProjectRepository; import net.modtale.service.analytics.ScoringService; +import net.modtale.service.jam.ModjamEmbargoService; import net.modtale.service.analytics.TrackingService; import net.modtale.service.project.query.ProjectService; import org.springframework.data.mongodb.core.MongoTemplate; @@ -26,6 +27,7 @@ public class ProjectDeletionService { private final ScoringService scoringService; private final ProjectArtifactDeletionService projectArtifactDeletionService; private final MongoTemplate mongoTemplate; + private final ModjamEmbargoService modjamEmbargoService; public ProjectDeletionService( ProjectRepository projectRepository, @@ -33,7 +35,8 @@ public ProjectDeletionService( TrackingService trackingService, ScoringService scoringService, ProjectArtifactDeletionService projectArtifactDeletionService, - MongoTemplate mongoTemplate + MongoTemplate mongoTemplate, + ModjamEmbargoService modjamEmbargoService ) { this.projectRepository = projectRepository; this.projectService = projectService; @@ -41,11 +44,13 @@ public ProjectDeletionService( this.scoringService = scoringService; this.projectArtifactDeletionService = projectArtifactDeletionService; this.mongoTemplate = mongoTemplate; + this.modjamEmbargoService = modjamEmbargoService; } public void softDelete(Project project) { ProjectStatus oldStatus = project.getStatus(); project.setStatus(ProjectStatus.DELETED); + project.setModjamPublicationPending(false); project.setDeletedAt(LocalDateTime.now()); scoringService.markProjectRankingDirty(project); projectRepository.save(project); @@ -56,7 +61,12 @@ public void softDelete(Project project) { } public void restore(Project project, ProjectStatus targetStatus) { + if ((targetStatus == ProjectStatus.PUBLISHED || targetStatus == ProjectStatus.UNLISTED || targetStatus == ProjectStatus.ARCHIVED) + && modjamEmbargoService.hasActiveEmbargo(project)) { + throw new net.modtale.exception.InvalidProjectRequestException("Restore this secret jam entry as private until voting opens."); + } project.setStatus(targetStatus); + project.setModjamPublicationPending(false); project.setDeletedAt(null); scoringService.markProjectRankingDirty(project); projectRepository.save(project); diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java index 7b992da2d..5a540ffe4 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java @@ -152,6 +152,10 @@ public Project createDraft(String title, String description, ProjectClassificati } public void submitProject(String id, User user) { + submitProjectForModjam(id, user, null); + } + + public void submitProjectForModjam(String id, User user, String jamId) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_SUBMIT", "You do not have permission to submit this project."); requireVerifiedEmail(user, "submit a project for review"); @@ -176,6 +180,10 @@ public void submitProject(String id, User user) { throw new InvalidProjectRequestException("Select a license before submitting this project."); } + if (jamId != null) { + if (project.getModjamIds() == null) project.setModjamIds(new ArrayList<>()); + if (!project.getModjamIds().contains(jamId)) project.getModjamIds().add(jamId); + } project.setStatus(ProjectStatus.PENDING); project.setExpiresAt(null); List scansQueuedForSubmission = new ArrayList<>(); diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java index dd81e9c5a..1c191a269 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java @@ -14,6 +14,8 @@ import net.modtale.service.analytics.TrackingService; import net.modtale.service.communication.ProjectNotificationService; import net.modtale.service.communication.WebhookService; +import net.modtale.service.jam.ModjamEmbargoService; +import net.modtale.service.jam.ModjamProjectReleasePersistence; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.access.AccessControlService; @@ -32,6 +34,8 @@ public class ProjectPublicationService { private final AccessControlService accessControlService; private final ProjectAccessService projectAccessService; private final SecurityIssueAnalysisService securityIssueAnalysisService; + private final ModjamEmbargoService modjamEmbargoService; + private final ModjamProjectReleasePersistence modjamProjectReleasePersistence; public ProjectPublicationService( ProjectRepository projectRepository, @@ -42,7 +46,9 @@ public ProjectPublicationService( ScoringService scoringService, AccessControlService accessControlService, ProjectAccessService projectAccessService, - SecurityIssueAnalysisService securityIssueAnalysisService + SecurityIssueAnalysisService securityIssueAnalysisService, + ModjamEmbargoService modjamEmbargoService, + ModjamProjectReleasePersistence modjamProjectReleasePersistence ) { this.projectRepository = projectRepository; this.projectService = projectService; @@ -53,6 +59,8 @@ public ProjectPublicationService( this.accessControlService = accessControlService; this.projectAccessService = projectAccessService; this.securityIssueAnalysisService = securityIssueAnalysisService; + this.modjamEmbargoService = modjamEmbargoService; + this.modjamProjectReleasePersistence = modjamProjectReleasePersistence; } public void revertProjectToDraft(String id, User user) { @@ -71,12 +79,14 @@ public void revertProjectToDraft(String id, User user) { public void archiveProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_ARCHIVE", "You do not have permission to archive this project."); + requireNoActiveEmbargo(project); if (project.getStatus() != ProjectStatus.PUBLISHED && project.getStatus() != ProjectStatus.UNLISTED && project.getStatus() != ProjectStatus.PRIVATE) { throw new InvalidProjectRequestException("Only published, unlisted, or private projects can be archived."); } project.setStatus(ProjectStatus.ARCHIVED); + project.setModjamPublicationPending(false); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); projectRepository.save(project); @@ -86,10 +96,12 @@ public void archiveProject(String id, User user) { public void unlistProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_UNLIST", "You do not have permission to unlist this project."); + requireNoActiveEmbargo(project); if (project.getStatus() != ProjectStatus.PUBLISHED && project.getStatus() != ProjectStatus.ARCHIVED) { throw new InvalidProjectRequestException("Only published or archived projects can be unlisted."); } project.setStatus(ProjectStatus.UNLISTED); + project.setModjamPublicationPending(false); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); projectRepository.save(project); @@ -103,6 +115,8 @@ public void privateProject(String id, User user) { throw new InvalidProjectRequestException("Pending or deleted projects cannot be made private."); } project.setStatus(ProjectStatus.PRIVATE); + // An explicit privacy choice cancels automatic jam publication. + project.setModjamPublicationPending(false); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); projectRepository.save(project); @@ -128,7 +142,9 @@ public void publishProject(String id, User user) { version.getScanResult() != null && version.getScanResult().getStatus() == ScanStatus.SCANNING)) { throw new VersionStateConflictException("Wait for the project scan to finish before publishing."); } - project.setStatus(ProjectStatus.PUBLISHED); + boolean embargoed = modjamEmbargoService.hasActiveEmbargo(project); + project.setStatus(embargoed ? ProjectStatus.PRIVATE : ProjectStatus.PUBLISHED); + project.setModjamPublicationPending(embargoed); project.setExpiresAt(null); project.setUpdatedAt(LocalDateTime.now().toString()); scoringService.markProjectRankingDirty(project); @@ -157,7 +173,7 @@ public void publishProject(String id, User user) { Project saved = projectRepository.save(project); projectService.evictProjectCache(saved); - if (isNew) { + if (isNew && !embargoed) { projectNotificationService.notifyNewProject(saved); webhookService.triggerWebhook(saved); webhookService.triggerDiscordWebhook(saved); @@ -165,13 +181,41 @@ public void publishProject(String id, User user) { } } + public void releaseModjamEmbargo(Project project) { + if (!project.isModjamPublicationPending() || project.getStatus() != ProjectStatus.PRIVATE + || project.getDeletedAt() != null || modjamEmbargoService.hasActiveEmbargo(project)) return; + if (project.getClassification() != net.modtale.model.project.ProjectClassification.MODPACK + && (project.getVersions() == null || project.getVersions().stream().noneMatch(version -> + version != null && version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED))) return; + if (project.getVersions() != null && project.getVersions().stream().anyMatch(version -> + version != null && version.getScanResult() != null + && version.getScanResult().getStatus() == ScanStatus.SCANNING)) return; + Project saved = modjamProjectReleasePersistence.claimRelease(project); + if (saved == null) return; + projectService.evictProjectCache(saved); + projectNotificationService.notifyNewProject(saved); + webhookService.triggerWebhook(saved); + webhookService.triggerDiscordWebhook(saved); + trackingService.logNewProject(saved.getId()); + } + public void updateProjectStatus(String id, ProjectStatus status, User user, String permissionRequired) { Project project = projectAccessService.requireProjectPermission(id, user, permissionRequired, "You do not have permission to update this project."); + if (status == ProjectStatus.PUBLISHED || status == ProjectStatus.UNLISTED || status == ProjectStatus.ARCHIVED) { + requireNoActiveEmbargo(project); + } project.setStatus(status); + project.setModjamPublicationPending(false); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); projectRepository.save(project); projectService.evictProjectCache(project); } + + private void requireNoActiveEmbargo(Project project) { + if (modjamEmbargoService.hasActiveEmbargo(project)) { + throw new InvalidProjectRequestException("This jam hides its entries until voting opens."); + } + } } diff --git a/backend/src/main/java/net/modtale/service/system/SitemapService.java b/backend/src/main/java/net/modtale/service/system/SitemapService.java index a92f86b86..32d2883bf 100644 --- a/backend/src/main/java/net/modtale/service/system/SitemapService.java +++ b/backend/src/main/java/net/modtale/service/system/SitemapService.java @@ -2,15 +2,18 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.HashSet; import java.util.List; import java.util.Set; import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.jam.Modjam; import net.modtale.model.project.Project; import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; +import net.modtale.service.ModjamService; import net.modtale.service.project.query.ProjectService; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -21,17 +24,20 @@ public class SitemapService { private final ProjectRepository projectRepository; private final UserRepository userRepository; private final ProjectService projectService; + private final ModjamService modjamService; private final String baseUrl; public SitemapService( ProjectRepository projectRepository, UserRepository userRepository, ProjectService projectService, + ModjamService modjamService, AppFrontendProperties frontendProperties ) { this.projectRepository = projectRepository; this.userRepository = userRepository; this.projectService = projectService; + this.modjamService = modjamService; this.baseUrl = frontendProperties.url(); } @@ -48,9 +54,29 @@ public String generateSitemap() { addUrl(xml, baseUrl + "/worlds", "0.9", today); addUrl(xml, baseUrl + "/data", "0.9", today); addUrl(xml, baseUrl + "/art", "0.9", today); + addUrl(xml, baseUrl + "/jams", "0.9", today); addUrl(xml, baseUrl + "/api-docs", "0.8", today); Set activeAuthors = new HashSet<>(); + List jams = modjamService.getAllJams(); + if (jams != null) { + for (Modjam jam : jams) { + if (jam == null || "DRAFT".equals(jam.getStatus())) continue; + + if (jam.getSlug() != null && !jam.getSlug().isBlank()) { + String priority = "COMPLETED".equals(jam.getStatus()) ? "0.6" : "0.8"; + LocalDate lastMod = jam.getUpdatedAt() != null + ? jam.getUpdatedAt().atZone(ZoneOffset.UTC).toLocalDate() + : today; + addUrl(xml, baseUrl + "/jam/" + jam.getSlug(), priority, lastMod); + } + + if (jam.getHostName() != null && !jam.getHostName().isBlank()) { + activeAuthors.add(jam.getHostName().trim()); + } + } + } + List projects = projectRepository.findAllForSitemap(); for (Project project : projects) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 040abf71d..f032e1783 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -126,6 +126,8 @@ app.webhook.key=${HYTALEMODDING_KEY:} app.discord-webhook.url=${DISCORD_WEBHOOK_URL:} app.admin-discord-webhook.url=${ADMIN_DISCORD_WEBHOOK_URL:} app.status-discord-webhook.url=${STATUS_DISCORD_WEBHOOK_URL:} +app.modjam-discord-feed.enabled=${MODJAM_DISCORD_FEED_ENABLED:false} +app.modjam-discord-feed.url=${MODJAM_DISCORD_FEED_URL:} app.status-checker.enabled=${STATUS_CHECKER_ENABLED:false} app.hytalemodding.wiki-url=https://wiki.hytalemodding.dev/api diff --git a/backend/src/test/java/net/modtale/config/core/EmbeddedTomcatCompatibilityTest.java b/backend/src/test/java/net/modtale/config/core/EmbeddedTomcatCompatibilityTest.java index 891955002..e631ce024 100644 --- a/backend/src/test/java/net/modtale/config/core/EmbeddedTomcatCompatibilityTest.java +++ b/backend/src/test/java/net/modtale/config/core/EmbeddedTomcatCompatibilityTest.java @@ -31,8 +31,8 @@ void embeddedTomcatModulesUseAnAlignedPatchedVersion() { String version = Tomcat.class.getPackage().getImplementationVersion(); assertNotNull(version); assertTrue( - Runtime.Version.parse(version).compareTo(Runtime.Version.parse("11.0.26")) >= 0, - "Embedded Tomcat must include the fixes released in 11.0.26"); + Runtime.Version.parse(version).compareTo(Runtime.Version.parse("11.0.27")) >= 0, + "Embedded Tomcat must include the fixes released in 11.0.27"); assertEquals(version, ExpressionFactoryImpl.class.getPackage().getImplementationVersion()); assertEquals(version, WsServerContainer.class.getPackage().getImplementationVersion()); } diff --git a/backend/src/test/java/net/modtale/config/security/PreviewCookiePolicyTest.java b/backend/src/test/java/net/modtale/config/security/PreviewCookiePolicyTest.java new file mode 100644 index 000000000..fe7da5898 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/security/PreviewCookiePolicyTest.java @@ -0,0 +1,93 @@ +package net.modtale.config.security; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import net.modtale.config.auth.ApiKeyAuthFilter; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; +import net.modtale.service.auth.LocalUserDetailsService; +import net.modtale.service.auth.OAuth2LoginService; +import net.modtale.service.auth.OidcLoginService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.session.web.http.CookieSerializer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class PreviewCookiePolicyTest { + @ParameterizedTest + @CsvSource({ + "https://preview-example.run.app, true, true, None, ''", + "https://PREVIEW-EXAMPLE.RUN.APP/, true, true, None, ''", + "https://dev.modtale.net, true, false, None, ''", + "https://modtale.net, true, false, Lax, modtale.net", + "https://www.modtale.net/, true, false, Lax, modtale.net", + "http://localhost:3000, false, false, Lax, ''", + "http://127.0.0.1:3000, false, false, Lax, ''", + "http://[::1]:3000, false, false, Lax, ''", + "https://preview.run.app.attacker.test, true, false, Lax, attacker.test" + }) + void sessionAndCsrfCookieCreationAndExpiryKeepTheirEnvironmentPolicy( + String frontendUrl, boolean secure, boolean partitioned, String sameSite, String domain) { + var config = configuration(frontendUrl); + // Exercise the raw-header path as well as the Servlet 6 path covered by + // PreviewSessionFlowTest. Mock responses omit SameSite for plain Cookies. + var servletContext = new MockServletContext(); + servletContext.setMajorVersion(5); + var request = new MockHttpServletRequest(servletContext); + var sessionResponse = new MockHttpServletResponse(); + var serializer = config.cookieSerializer(); + serializer.writeCookieValue(new CookieSerializer.CookieValue(request, sessionResponse, "fixture-session")); + assertPolicy(sessionResponse.getHeader("Set-Cookie"), "SESSION", secure, true, partitioned, sameSite, domain, false); + + var expiredSessionResponse = new MockHttpServletResponse(); + serializer.writeCookieValue(new CookieSerializer.CookieValue(request, expiredSessionResponse, "")); + assertPolicy(expiredSessionResponse.getHeader("Set-Cookie"), "SESSION", secure, true, partitioned, sameSite, domain, true); + + var repository = config.csrfTokenRepository(); + var token = repository.generateToken(request); + var csrfResponse = new MockHttpServletResponse(); + repository.saveToken(token, request, csrfResponse); + assertPolicy(csrfResponse.getHeader("Set-Cookie"), "XSRF-TOKEN", secure, false, partitioned, sameSite, domain, false); + + var expiredCsrfResponse = new MockHttpServletResponse(); + repository.saveToken(null, request, expiredCsrfResponse); + assertPolicy(expiredCsrfResponse.getHeader("Set-Cookie"), "XSRF-TOKEN", secure, false, partitioned, sameSite, domain, true); + } + + private static void assertPolicy(String header, String name, boolean secure, boolean httpOnly, + boolean partitioned, String sameSite, String domain, boolean expired) { + assertNotNull(header); + assertTrue(header.startsWith(name + "="), header); + Set attributes = Arrays.stream(header.split(";")).skip(1).map(String::trim).collect(Collectors.toSet()); + assertTrue(attributes.contains("Path=/"), header); + assertEquals(secure, attributes.contains("Secure"), header); + assertEquals(httpOnly, attributes.contains("HttpOnly"), header); + assertEquals(partitioned, attributes.contains("Partitioned"), header); + assertTrue(attributes.contains("SameSite=" + sameSite), header); + assertEquals(domain.isEmpty() ? Set.of() : Set.of("Domain=" + domain), + attributes.stream().filter(attribute -> attribute.startsWith("Domain=")).collect(Collectors.toSet()), header); + assertEquals(expired, attributes.contains("Max-Age=0"), header); + } + + private static SecurityConfig configuration(String frontendUrl) { + return new SecurityConfig( + mock(ApiKeyAuthFilter.class), mock(RateLimitFilter.class), + mock(OAuth2LoginService.class), mock(OidcLoginService.class), + mock(OAuth2AuthorizedClientRepository.class), mock(LocalUserDetailsService.class), + mock(PasswordEncoder.class), mock(AccountService.class), + mock(AuthenticationService.class), mock(LauncherAuthService.class), + new AppFrontendProperties(frontendUrl)); + } +} diff --git a/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java b/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java index fd247c598..3892a1ed0 100644 --- a/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java +++ b/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java @@ -44,6 +44,30 @@ void localhostTextInsideRemoteFrontendDoesNotDisableSecureCookies() { assertTrue(response.getHeader("Set-Cookie").contains("Secure")); } + @Test + void previewWritesStillRequireTheTokenMatchingTheCsrfCookie() throws Exception { + try (var context = new AnnotationConfigWebApplicationContext()) { + context.setServletContext(new MockServletContext()); + context.register(TestSecurity.class); + context.refresh(); + var csrf = context.getBean(SecurityFilterChain.class).getFilters().stream() + .filter(CsrfFilter.class::isInstance).map(CsrfFilter.class::cast).findFirst().orElseThrow(); + for (boolean cookie : new boolean[]{false, true}) { + for (String header : new String[]{"", "fixture-csrf", "different-token"}) { + var request = new org.springframework.mock.web.MockHttpServletRequest("POST", "/api/v1/auth/logout"); + var response = new org.springframework.mock.web.MockHttpServletResponse(); + var reached = new java.util.concurrent.atomic.AtomicBoolean(); + if (cookie) request.setCookies(new jakarta.servlet.http.Cookie("XSRF-TOKEN", "fixture-csrf")); + if (!header.isEmpty()) request.addHeader("X-XSRF-TOKEN", header); + csrf.doFilter(request, response, (req, res) -> reached.set(true)); + boolean valid = cookie && "fixture-csrf".equals(header); + assertEquals(valid, reached.get()); + if (!valid) assertEquals(403, response.getStatus()); + } + } + } + } + @Test void enrollmentAuthorizationRejectsApiKeysAndRequiresBrowserCsrf() throws Exception { try (var context = new AnnotationConfigWebApplicationContext()) { diff --git a/backend/src/test/java/net/modtale/controller/ModjamControllerSessionTest.java b/backend/src/test/java/net/modtale/controller/ModjamControllerSessionTest.java new file mode 100644 index 000000000..3e96994fb --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/ModjamControllerSessionTest.java @@ -0,0 +1,126 @@ +package net.modtale.controller; + +import java.lang.reflect.InvocationTargetException; +import java.util.List; +import net.modtale.exception.ApiKeyOperationForbiddenException; +import net.modtale.model.jam.Modjam; +import net.modtale.model.user.User; +import net.modtale.service.ModjamService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ModjamControllerSessionTest { + private ModjamController controller; + private ModjamService modjamService; + private AccountService accountService; + private AccessControlService accessControlService; + + @BeforeEach + void setUp() { + controller = new ModjamController(); + modjamService = mock(ModjamService.class); + accountService = mock(AccountService.class); + accessControlService = mock(AccessControlService.class); + ReflectionTestUtils.setField(controller, "modjamService", modjamService); + ReflectionTestUtils.setField(controller, "accountService", accountService); + ReflectionTestUtils.setField(controller, "accessControlService", accessControlService); + } + + @AfterEach + void clearAuthentication() { + SecurityContextHolder.clearContext(); + } + + @Test + void everyMutationRejectsApiKeysBeforeReadingUserOrCallingService() { + var authentication = new UsernamePasswordAuthenticationToken("owner", null, + List.of(new SimpleGrantedAuthority("ROLE_API"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + when(accessControlService.isApiKey(authentication)).thenReturn(true); + + int checked = 0; + for (var method : ModjamController.class.getDeclaredMethods()) { + if (!method.isAnnotationPresent(PostMapping.class) + && !method.isAnnotationPresent(PutMapping.class) + && !method.isAnnotationPresent(DeleteMapping.class)) continue; + Object[] arguments = new Object[method.getParameterCount()]; + var error = assertThrows(InvocationTargetException.class, + () -> method.invoke(controller, arguments), method.getName()); + assertInstanceOf(ApiKeyOperationForbiddenException.class, error.getCause(), method.getName()); + checked++; + } + assertTrue(checked >= 13); + verifyNoInteractions(accountService, modjamService); + } + + @Test + void hostedJamListRequiresBrowserSession() { + var authentication = new UsernamePasswordAuthenticationToken("owner", null, + List.of(new SimpleGrantedAuthority("ROLE_API"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + when(accessControlService.isApiKey(authentication)).thenReturn(true); + assertThrows(ApiKeyOperationForbiddenException.class, controller::getMyJams); + verifyNoInteractions(accountService, modjamService); + } + + @Test + void browserMutationUsesAuthenticatedUser() { + User user = new User(); + user.setId("user-1"); + user.setUsername("creator"); + when(accountService.getCurrentUser()).thenReturn(user); + Modjam jam = new Modjam(); + controller.createJam(jam); + verify(modjamService).createJam(jam, "user-1", "creator"); + } + + @Test + void anonymousMutationCannotCallService() { + assertEquals(401, controller.createJam(new Modjam()).getStatusCode().value()); + verifyNoInteractions(modjamService); + } + + @Test + void typedVoteBodyRejectsMissingFractionalAndMalformedFieldsAsBadRequests() throws Exception { + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new net.modtale.exception.GlobalExceptionHandler()).build(); + for (String body : List.of("{}", "{\"submissionId\":\"sub\",\"categoryId\":\"cat\",\"score\":1.5}", + "{\"submissionId\":\"sub\",\"categoryId\":\"cat\",\"score\":{}}")) { + mvc.perform(post("/api/v1/modjams/jam-1/vote").contentType("application/json").content(body)) + .andExpect(status().isBadRequest()); + } + verifyNoInteractions(modjamService); + } + + @Test + void voteErrorPreservesItsHttpStatusThroughGlobalAdvice() throws Exception { + User user = new User(); + user.setId("user-1"); + when(accountService.getCurrentUser()).thenReturn(user); + when(modjamService.vote("jam-1", "sub", "cat", 5, "user-1")) + .thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "Submission not found in this jam.")); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new net.modtale.exception.GlobalExceptionHandler()).build(); + mvc.perform(post("/api/v1/modjams/jam-1/vote").contentType("application/json") + .content("{\"submissionId\":\"sub\",\"categoryId\":\"cat\",\"score\":5}")) + .andExpect(status().isNotFound()); + } +} diff --git a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java index 7cc027300..29ff6fe07 100644 --- a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java @@ -17,6 +17,7 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.csrf.CsrfTokenRepository; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -35,6 +36,7 @@ class AuthControllerTest { private TwoFactorService twoFactorService; private LauncherAuthService launcherAuthService; private SecurityContextRepository securityContextRepository; + private CsrfTokenRepository csrfTokenRepository; @BeforeEach void setUp() { @@ -44,6 +46,7 @@ void setUp() { twoFactorService = mock(TwoFactorService.class); launcherAuthService = mock(LauncherAuthService.class); securityContextRepository = mock(SecurityContextRepository.class); + csrfTokenRepository = mock(CsrfTokenRepository.class); controller = new AuthController( authenticationService, authenticationMutationService, @@ -51,7 +54,8 @@ void setUp() { twoFactorService, launcherAuthService, securityContextRepository, - mock(net.modtale.service.auth.MfaEnrollmentService.class) + mock(net.modtale.service.auth.MfaEnrollmentService.class), + csrfTokenRepository ); } @@ -92,6 +96,7 @@ void logoutClearsTheSecurityContextAndExpiresSessionCookies() { assertEquals(200, result.getStatusCode().value()); verify(securityContextRepository).saveContext(org.springframework.security.core.context.SecurityContextHolder.createEmptyContext(), request, response); + verify(csrfTokenRepository).saveToken(null, request, response); } @Test diff --git a/backend/src/test/java/net/modtale/controller/auth/LoginSessionRotationTest.java b/backend/src/test/java/net/modtale/controller/auth/LoginSessionRotationTest.java index 09daab540..e7bd5baaa 100644 --- a/backend/src/test/java/net/modtale/controller/auth/LoginSessionRotationTest.java +++ b/backend/src/test/java/net/modtale/controller/auth/LoginSessionRotationTest.java @@ -35,7 +35,7 @@ class LoginSessionRotationTest { @Bean AccountService accounts(){return mock(AccountService.class);} @Bean SecurityContextRepository sessions(){return new HttpSessionSecurityContextRepository();} @Bean ClientRegistrationRepository clients(){return mock(ClientRegistrationRepository.class);} - @Bean AuthController controller(AuthenticationService a,LauncherAuthService l,TwoFactorService f,AccountService u,SecurityContextRepository s){return new AuthController(a,mock(AuthenticationMutationService.class),u,f,l,s,mock(MfaEnrollmentService.class));} + @Bean AuthController controller(AuthenticationService a,LauncherAuthService l,TwoFactorService f,AccountService u,SecurityContextRepository s){return new AuthController(a,mock(AuthenticationMutationService.class),u,f,l,s,mock(MfaEnrollmentService.class),mock(org.springframework.security.web.csrf.CsrfTokenRepository.class));} @Bean SecurityFilterChain chain(HttpSecurity http,AuthenticationService auth,LauncherAuthService launcher,AccountService accounts)throws Exception { var keys=mock(ApiKeyService.class); var config=new SecurityConfig(new ApiKeyAuthFilter(keys,(req,res,h,e)->{res.setStatus(401);return new org.springframework.web.servlet.ModelAndView();}),new RateLimitFilter(keys), diff --git a/backend/src/test/java/net/modtale/controller/auth/PreviewSessionFlowTest.java b/backend/src/test/java/net/modtale/controller/auth/PreviewSessionFlowTest.java new file mode 100644 index 000000000..5ad8be5f6 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/auth/PreviewSessionFlowTest.java @@ -0,0 +1,169 @@ +package net.modtale.controller.auth; + +import jakarta.servlet.Filter; +import jakarta.servlet.http.Cookie; +import java.util.concurrent.ConcurrentHashMap; +import net.modtale.config.auth.ApiKeyAuthFilter; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.config.security.RateLimitFilter; +import net.modtale.config.security.SecurityConfig; +import net.modtale.model.user.User; +import net.modtale.service.auth.*; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.session.MapSession; +import org.springframework.session.MapSessionRepository; +import org.springframework.session.web.http.CookieHttpSessionIdResolver; +import org.springframework.session.web.http.SessionRepositoryFilter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class PreviewSessionFlowTest { + private static final String FRONTEND = "https://frontend-preview.run.app"; + private AnnotationConfigWebApplicationContext context; + private MockMvc mvc; + + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class Config { + @Bean AuthenticationService authentication() { return mock(AuthenticationService.class); } + @Bean LauncherAuthService launcher() { return mock(LauncherAuthService.class); } + @Bean AccountService accounts() { return mock(AccountService.class); } + @Bean ClientRegistrationRepository clients() { return mock(ClientRegistrationRepository.class); } + @Bean SecurityConfig securityConfig(AuthenticationService auth, LauncherAuthService launcher, AccountService accounts) { + var keys = mock(ApiKeyService.class); + return new SecurityConfig( + new ApiKeyAuthFilter(keys, (req, res, handler, ex) -> { + res.setStatus(401); + return new org.springframework.web.servlet.ModelAndView(); + }), + new RateLimitFilter(keys), mock(OAuth2LoginService.class), mock(OidcLoginService.class), + mock(OAuth2AuthorizedClientRepository.class), mock(LocalUserDetailsService.class), + mock(PasswordEncoder.class), accounts, auth, launcher, new AppFrontendProperties(FRONTEND)); + } + @Bean SecurityFilterChain chain(HttpSecurity http, SecurityConfig config) throws Exception { + return config.securityFilterChain(http, mock(OAuth2AuthorizationRequestResolver.class)); + } + @Bean AuthController controller(AuthenticationService auth, LauncherAuthService launcher, + AccountService accounts, SecurityConfig config) { + return new AuthController(auth, mock(AuthenticationMutationService.class), accounts, + mock(TwoFactorService.class), launcher, config.securityContextRepository(), + mock(MfaEnrollmentService.class), config.csrfTokenRepository()); + } + @Bean FixtureController fixtureController() { return new FixtureController(); } + } + + @RestController + static class FixtureController { + @GetMapping("/api/v1/fixture/me") + String currentUser(Authentication authentication) { + return ((User) authentication.getPrincipal()).getUsername(); + } + } + + @BeforeEach + void setup() { + context = new AnnotationConfigWebApplicationContext(); + context.setServletContext(new MockServletContext()); + context.register(Config.class); + context.refresh(); + var sessions = new MapSessionRepository(new ConcurrentHashMap<>()); + var sessionFilter = new SessionRepositoryFilter(sessions); + var resolver = new CookieHttpSessionIdResolver(); + resolver.setCookieSerializer(context.getBean(SecurityConfig.class).cookieSerializer()); + sessionFilter.setHttpSessionIdResolver(resolver); + mvc = MockMvcBuilders.webAppContextSetup(context) + .addFilters(sessionFilter, context.getBean("springSecurityFilterChain", Filter.class)).build(); + User fixture = new User(); + fixture.setId("fixture-user"); + fixture.setUsername("fixture"); + fixture.setRoles(java.util.List.of("USER")); + when(context.getBean(AuthenticationService.class).authenticate("fixture", "fixture-password")).thenReturn(fixture); + } + + @AfterEach + void cleanup() { + context.close(); + SecurityContextHolder.clearContext(); + } + + @Test + void freshCredentialedRequestsRestoreSessionAndLogoutExpiresPartitionedCookies() throws Exception { + var login = signIn(); + var session = responseCookie(login, "SESSION"); + var csrf = responseCookie(login, "XSRF-TOKEN"); + mvc.perform(get("/api/v1/fixture/me").cookie(session, csrf) + .header("User-Agent", "Mozilla/5.0").header("Origin", FRONTEND)) + .andExpect(status().isOk()).andExpect(content().string("fixture")); + + mvc.perform(post("/api/v1/auth/logout").cookie(session, csrf) + .header("User-Agent", "Mozilla/5.0").header("Origin", FRONTEND)) + .andExpect(status().isForbidden()); + // A rejected logout must not destroy the authenticated session. + mvc.perform(get("/api/v1/fixture/me").cookie(session, csrf) + .header("User-Agent", "Mozilla/5.0").header("Origin", FRONTEND)) + .andExpect(status().isOk()); + + var logout = mvc.perform(post("/api/v1/auth/logout").cookie(session, csrf) + .header("X-XSRF-TOKEN", csrf.getValue()) + .header("User-Agent", "Mozilla/5.0").header("Origin", FRONTEND)) + .andExpect(status().isOk()).andReturn(); + for (String name : new String[]{"SESSION", "XSRF-TOKEN"}) { + assertTrue(logout.getResponse().getHeaders("Set-Cookie").stream().anyMatch(header -> + header.startsWith(name + "=") && header.contains("Max-Age=0") + && header.contains("Secure") && header.contains("SameSite=None") + && header.contains("Partitioned")), logout.getResponse().getHeaders("Set-Cookie").toString()); + } + mvc.perform(get("/api/v1/fixture/me").cookie(session, csrf) + .header("User-Agent", "Mozilla/5.0").header("Origin", FRONTEND)) + .andExpect(status().isUnauthorized()); + + // Sign-in after logout creates a new authenticated session. + var nextLogin = signIn(); + assertNotEquals(session.getValue(), responseCookie(nextLogin, "SESSION").getValue()); + mvc.perform(get("/api/v1/fixture/me").cookie(responseCookie(nextLogin, "SESSION")) + .header("User-Agent", "Mozilla/5.0").header("Origin", FRONTEND)) + .andExpect(status().isOk()).andExpect(content().string("fixture")); + } + + private MvcResult signIn() throws Exception { + return mvc.perform(post("/api/v1/auth/signin") + .header("User-Agent", "Mozilla/5.0").header("Origin", FRONTEND) + .contentType("application/json") + .content("{\"username\":\"fixture\",\"password\":\"fixture-password\"}")) + .andExpect(status().isOk()).andReturn(); + } + + private static Cookie responseCookie(MvcResult result, String name) { + String header = result.getResponse().getHeaders("Set-Cookie").stream() + .filter(value -> value.startsWith(name + "=")).findFirst().orElseThrow(); + assertTrue(header.contains("Secure") && header.contains("SameSite=None") && header.contains("Partitioned"), header); + if ("SESSION".equals(name)) assertTrue(header.contains("HttpOnly"), header); + return new Cookie(name, header.substring(name.length() + 1, header.indexOf(';'))); + } +} diff --git a/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java b/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java index a93bf2749..af8704c48 100644 --- a/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/system/SitemapControllerTest.java @@ -8,6 +8,7 @@ import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; +import net.modtale.service.ModjamService; import net.modtale.service.project.query.ProjectCacheService; import net.modtale.service.project.query.ProjectRouteService; import net.modtale.service.project.query.ProjectService; @@ -24,6 +25,7 @@ class SitemapControllerTest { private ProjectRepository projectRepository; private UserRepository userRepository; + private ModjamService modjamService; private ProjectService projectService; private SitemapController controller; @@ -31,6 +33,7 @@ class SitemapControllerTest { void setUp() { projectRepository = mock(ProjectRepository.class); userRepository = mock(UserRepository.class); + modjamService = mock(ModjamService.class); projectService = new ProjectService( mock(ProjectViewService.class), mock(ProjectCacheService.class), @@ -40,9 +43,11 @@ void setUp() { projectRepository, userRepository, projectService, + modjamService, new AppFrontendProperties("https://modtale.test") ); controller = new SitemapController(sitemapService); + when(modjamService.getAllJams()).thenReturn(List.of()); } @Test diff --git a/backend/src/test/java/net/modtale/repository/jam/MongoModjamDiscordOutboxStoreTest.java b/backend/src/test/java/net/modtale/repository/jam/MongoModjamDiscordOutboxStoreTest.java new file mode 100644 index 000000000..5a3867900 --- /dev/null +++ b/backend/src/test/java/net/modtale/repository/jam/MongoModjamDiscordOutboxStoreTest.java @@ -0,0 +1,103 @@ +package net.modtale.repository.jam; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.time.Instant; +import net.modtale.model.jam.ModjamDiscordEvent; +import net.modtale.model.jam.ModjamDiscordEvent.Milestone; +import net.modtale.model.jam.ModjamDiscordFeedBackoff; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +class MongoModjamDiscordOutboxStoreTest { + private final MongoTemplate mongo = mock(MongoTemplate.class); + private final MongoModjamDiscordOutboxStore store = new MongoModjamDiscordOutboxStore(mongo); + private final Instant now = Instant.parse("2026-10-01T12:00:00Z"); + + @Test + void duplicateIdInsertDoesNotOverwriteDeliveredOrPendingRecord() { + var event = event(); + when(mongo.insert(event)).thenThrow(new DuplicateKeyException("duplicate")); + assertDoesNotThrow(() -> store.enqueue(event)); + verify(mongo).insert(event); + verify(mongo, never()).save(any()); + assertEquals(event.getId(), event().getId()); + } + + @Test + void atomicClaimReturnsNewLeaseAndRequiresDueUnleasedPendingState() { + store.claimNext(now, Duration.ofMinutes(2)); + var query = ArgumentCaptor.forClass(Query.class); + var update = ArgumentCaptor.forClass(Update.class); + var options = ArgumentCaptor.forClass(FindAndModifyOptions.class); + verify(mongo).findAndModify(query.capture(), update.capture(), options.capture(), eq(ModjamDiscordEvent.class)); + String filter = query.getValue().getQueryObject().toString(); + assertTrue(filter.contains("state")); + assertTrue(filter.contains("PENDING")); + assertTrue(filter.contains("nextAttemptAt")); + assertTrue(filter.contains("leaseUntil")); + assertTrue(options.getValue().isReturnNew()); + var changes = update.getValue().getUpdateObject().get("$set", org.bson.Document.class); + assertEquals(now.plusSeconds(120), changes.get("leaseUntil")); + assertNotNull(changes.get("leaseToken")); + assertEquals(1, update.getValue().getUpdateObject().get("$inc", org.bson.Document.class).get("attempts")); + } + + @Test + void sharedRateLimitPausePreventsClaimAfterProcessRestart() { + when(mongo.findById("modjam-feed", ModjamDiscordFeedBackoff.class)) + .thenReturn(new ModjamDiscordFeedBackoff("modjam-feed", now.plusSeconds(90), now)); + assertTrue(new MongoModjamDiscordOutboxStore(mongo).claimNext(now, Duration.ofMinutes(2)).isEmpty()); + verify(mongo, never()).findAndModify(any(Query.class), any(Update.class), + any(FindAndModifyOptions.class), eq(ModjamDiscordEvent.class)); + } + + @Test + void deliveryAcknowledgementRequiresOriginalLeaseToken() { + var event = event(); + event.setLeaseToken("claim-1"); + store.delivered(event, now); + var query = ArgumentCaptor.forClass(Query.class); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(query.capture(), update.capture(), eq(ModjamDiscordEvent.class)); + assertEquals(event.getId(), query.getValue().getQueryObject().get("_id")); + assertEquals("claim-1", query.getValue().getQueryObject().get("leaseToken")); + var changes = update.getValue().getUpdateObject().get("$set", org.bson.Document.class); + assertEquals(now, changes.get("deliveredAt")); + assertEquals(ModjamDiscordEvent.State.DELIVERED, changes.get("state")); + } + + @Test + void activationUsesAtomicUniqueIdUpsertAndSurvivesRestarts() { + when(mongo.findAndModify(any(Query.class), any(Update.class), any(FindAndModifyOptions.class), + eq(ModjamDiscordFeedBackoff.class))) + .thenReturn(new ModjamDiscordFeedBackoff("modjam-feed", null, now)); + assertEquals(now, store.activationTime(now)); + var options = ArgumentCaptor.forClass(FindAndModifyOptions.class); + verify(mongo).findAndModify(any(Query.class), any(Update.class), options.capture(), eq(ModjamDiscordFeedBackoff.class)); + assertTrue(options.getValue().isUpsert()); + assertTrue(options.getValue().isReturnNew()); + when(mongo.findById("modjam-feed", ModjamDiscordFeedBackoff.class)) + .thenReturn(new ModjamDiscordFeedBackoff("modjam-feed", null, now)); + assertEquals(now, new MongoModjamDiscordOutboxStore(mongo).activationTime(now.plusSeconds(3600))); + } + + @Test + void concurrentActivationConflictReadsWinningTimestampInsteadOfResettingIt() { + when(mongo.findById("modjam-feed", ModjamDiscordFeedBackoff.class)).thenReturn(null) + .thenReturn(new ModjamDiscordFeedBackoff("modjam-feed", null, now.minusSeconds(1))); + when(mongo.findAndModify(any(Query.class), any(Update.class), any(FindAndModifyOptions.class), + eq(ModjamDiscordFeedBackoff.class))).thenThrow(new DuplicateKeyException("concurrent activation")); + assertEquals(now.minusSeconds(1), store.activationTime(now)); + } + + private ModjamDiscordEvent event() { return new ModjamDiscordEvent("jam-test", Milestone.CREATED, now, now); } +} diff --git a/backend/src/test/java/net/modtale/service/ModjamFinalizationTest.java b/backend/src/test/java/net/modtale/service/ModjamFinalizationTest.java new file mode 100644 index 000000000..25fcd9dd7 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/ModjamFinalizationTest.java @@ -0,0 +1,171 @@ +package net.modtale.service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamSubmission; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.jam.ModjamSubmissionRepository; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ModjamFinalizationTest { + private ModjamService service; + private ModjamRepository jamRepository; + private ModjamSubmissionRepository submissionRepository; + private Modjam jam; + private ModjamSubmission submission; + private org.springframework.data.mongodb.core.MongoTemplate mongo; + private net.modtale.service.jam.ModjamVotePersistence votePersistence; + + @BeforeEach + void setUp() { + service = new ModjamService(); + jamRepository = mock(ModjamRepository.class); + submissionRepository = mock(ModjamSubmissionRepository.class); + ReflectionTestUtils.setField(service, "modjamRepository", jamRepository); + ReflectionTestUtils.setField(service, "feedService", mock(net.modtale.service.jam.ModjamDiscordFeedService.class)); + ReflectionTestUtils.setField(service, "submissionRepository", submissionRepository); + votePersistence = mock(net.modtale.service.jam.ModjamVotePersistence.class); + ReflectionTestUtils.setField(service, "votePersistence", votePersistence); + mongo = mock(org.springframework.data.mongodb.core.MongoTemplate.class); + when(mongo.updateFirst(any(org.springframework.data.mongodb.core.query.Query.class), any(org.springframework.data.mongodb.core.query.Update.class), eq(Modjam.class))) + .thenReturn(com.mongodb.client.result.UpdateResult.acknowledged(1, 1L, null)); + ReflectionTestUtils.setField(service, "mongoTemplate", mongo); + ReflectionTestUtils.setField(service, "userRepository", mock(UserRepository.class)); + ReflectionTestUtils.setField(service, "organizerService", new net.modtale.service.jam.ModjamOrganizerService( + jamRepository, mock(UserRepository.class), mock(org.springframework.data.mongodb.core.MongoTemplate.class))); + jam = new Modjam(); + jam.setId("jam-1"); + jam.setHostId("host-1"); + jam.setStatus("AWAITING_WINNERS"); + jam.setStartDate(Instant.now().minusSeconds(7200)); + jam.setEndDate(Instant.now().minusSeconds(3600)); + jam.setVotingEndDate(Instant.now().minusSeconds(1)); + submission = new ModjamSubmission(); + submission.setId("submission-1"); + submission.setJamId("jam-1"); + when(jamRepository.findById("jam-1")).thenReturn(Optional.of(jam)); + when(mongo.findAndModify(any(org.springframework.data.mongodb.core.query.Query.class), + any(org.springframework.data.mongodb.core.query.Update.class), + any(org.springframework.data.mongodb.core.FindAndModifyOptions.class), eq(Modjam.class))).thenReturn(jam); + when(mongo.updateFirst(any(org.springframework.data.mongodb.core.query.Query.class), + any(org.springframework.data.mongodb.core.query.Update.class), eq(Modjam.class))) + .thenReturn(com.mongodb.client.result.UpdateResult.acknowledged(1, 1L, null)); + when(submissionRepository.findByJamId("jam-1")).thenAnswer( + invocation -> new ArrayList<>(List.of(submission))); + when(jamRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(mongo.findAndModify(any(org.springframework.data.mongodb.core.query.Query.class), any(org.springframework.data.mongodb.core.query.Update.class), + any(org.springframework.data.mongodb.core.FindAndModifyOptions.class), eq(Modjam.class))).thenAnswer(invocation -> jam); + } + + @Test + void cannotFinalizeBeforeVotingDeadlineDespiteStaleStatus() { + jam.setVotingEndDate(Instant.now().plusSeconds(3600)); + assertThrows(IllegalArgumentException.class, + () -> service.finalizeJam("jam-1", "host-1", winners())); + verifyNoInteractions(submissionRepository); + verify(jamRepository, never()).save(any()); + } + + @Test + void staleVotingStatusCanFinalizeOnceDeadlinePasses() { + jam.setStatus("VOTING"); + service.finalizeJam("jam-1", "host-1", winners()); + assertEquals("COMPLETED", jam.getStatus()); + assertTrue(submission.isWinner()); + assertEquals("Best Overall", submission.getAwardTitle()); + } + + @Test + void delegatedAnnouncerFinalizesWithNarrowStatusAndAwardWrites() { + jam.setOrganizerRoles(List.of(new Modjam.OrganizerRole("announce", "Announcer", "#123456", + Set.of(Modjam.JamPermission.ANNOUNCE_WINNERS)))); + jam.setOrganizerMembers(List.of(new Modjam.OrganizerMember("announcer", "announce"))); + service.finalizeJam("jam-1", "announcer", winners()); + assertEquals("COMPLETED", jam.getStatus()); + verify(votePersistence).saveAward(submission); + verify(votePersistence).saveScores(submission); + var update = org.mockito.ArgumentCaptor.forClass(org.springframework.data.mongodb.core.query.Update.class); + verify(mongo).updateFirst(any(org.springframework.data.mongodb.core.query.Query.class), update.capture(), eq(Modjam.class)); + assertEquals(Set.of("status", "updatedAt", "winnersAnnouncedAt"), + update.getValue().getUpdateObject().get("$set", org.bson.Document.class).keySet()); + verify(jamRepository, never()).save(any()); + verify(submissionRepository, never()).save(any()); + } + + @Test + void rejectsWinnerFromAnotherJamBeforeCalculatingOrSaving() { + assertThrows(IllegalArgumentException.class, + () -> service.finalizeJam("jam-1", "host-1", List.of( + Map.of("submissionId", "foreign-submission", "awardTitle", "Winner")))); + verify(submissionRepository, times(1)).findByJamId("jam-1"); + verify(submissionRepository, never()).save(any()); + verify(jamRepository, never()).save(any()); + assertFalse(submission.isWinner()); + } + + @Test + void malformedWinnerInputCannotPartiallyFinalize() { + List>> invalidRequests = new ArrayList<>(); + invalidRequests.add(null); + invalidRequests.add(java.util.Arrays.asList((Map) null)); + invalidRequests.add(List.of(Map.of("awardTitle", "Winner"))); + invalidRequests.add(List.of(Map.of("submissionId", "submission-1"))); + invalidRequests.add(List.of(Map.of("submissionId", "submission-1", "awardTitle", " "))); + invalidRequests.add(List.of(winners().getFirst(), winners().getFirst())); + for (var request : invalidRequests) { + assertThrows(IllegalArgumentException.class, + () -> service.finalizeJam("jam-1", "host-1", request)); + } + assertEquals("AWAITING_WINNERS", jam.getStatus()); + assertFalse(submission.isWinner()); + verify(submissionRepository, never()).save(any()); + verify(jamRepository, never()).save(any()); + } + + @Test + void identicalFinalizationReplayDoesNotWriteAgain() { + service.finalizeJam("jam-1", "host-1", winners()); + clearInvocations(jamRepository, submissionRepository); + service.finalizeJam("jam-1", "host-1", winners()); + verify(submissionRepository, never()).save(any()); + verify(jamRepository, never()).save(any()); + } + + @Test + void competingFinalizationClaimCannotWriteAwards() { + when(mongo.findAndModify(any(org.springframework.data.mongodb.core.query.Query.class), any(org.springframework.data.mongodb.core.query.Update.class), + any(org.springframework.data.mongodb.core.FindAndModifyOptions.class), eq(Modjam.class))).thenReturn(null); + var error = assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.finalizeJam("jam-1", "host-1", winners())); + assertEquals(org.springframework.http.HttpStatus.CONFLICT, error.getStatusCode()); + verifyNoInteractions(votePersistence); + verify(mongo, never()).updateFirst(any(org.springframework.data.mongodb.core.query.Query.class), any(org.springframework.data.mongodb.core.query.Update.class), eq(Modjam.class)); + } + + @Test + void completedJamCannotReplaceWinnersOnReplay() { + service.finalizeJam("jam-1", "host-1", winners()); + clearInvocations(jamRepository, submissionRepository); + assertThrows(IllegalArgumentException.class, + () -> service.finalizeJam("jam-1", "host-1", List.of())); + assertTrue(submission.isWinner()); + verify(submissionRepository, never()).save(any()); + verify(jamRepository, never()).save(any()); + } + + private static List> winners() { + return List.of(Map.of("submissionId", "submission-1", "awardTitle", "Best Overall")); + } +} diff --git a/backend/src/test/java/net/modtale/service/ModjamManagementTest.java b/backend/src/test/java/net/modtale/service/ModjamManagementTest.java new file mode 100644 index 000000000..e2585341b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/ModjamManagementTest.java @@ -0,0 +1,176 @@ +package net.modtale.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.Modjam.JamPermission; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.jam.ModjamCustomizationService; +import net.modtale.service.jam.ModjamOrganizerService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +class ModjamManagementTest { + private ModjamService service; + private ModjamRepository jams; + private UserRepository users; + private AccountService account; + private MongoTemplate mongo; + private Modjam jam; + + @BeforeEach + void setUp() { + service = new ModjamService(); + jams = mock(ModjamRepository.class); + users = mock(UserRepository.class); + account = mock(AccountService.class); + mongo = mock(MongoTemplate.class); + when(mongo.updateFirst(any(org.springframework.data.mongodb.core.query.Query.class), any(org.springframework.data.mongodb.core.query.Update.class), eq(Modjam.class))) + .thenReturn(com.mongodb.client.result.UpdateResult.acknowledged(1, 1L, null)); + ReflectionTestUtils.setField(service, "modjamRepository", jams); + ReflectionTestUtils.setField(service, "feedService", mock(net.modtale.service.jam.ModjamDiscordFeedService.class)); + ReflectionTestUtils.setField(service, "userRepository", users); + ReflectionTestUtils.setField(service, "accountService", account); + ReflectionTestUtils.setField(service, "mongoTemplate", mongo); + ReflectionTestUtils.setField(service, "membershipPersistence", new net.modtale.service.jam.ModjamMembershipPersistence(mongo)); + ReflectionTestUtils.setField(service, "customizationService", mock(ModjamCustomizationService.class)); + ReflectionTestUtils.setField(service, "organizerService", new ModjamOrganizerService(jams, users, mock(MongoTemplate.class))); + jam = new Modjam(); + jam.setId("jam-1"); + jam.setSlug("new-jam"); + jam.setTitle("New Jam"); + jam.setHostId("host"); + jam.setOrganizerRoles(new ArrayList<>(List.of(new Modjam.OrganizerRole("editor", "Editor", "#123456", Set.of(JamPermission.EDIT_DETAILS))))); + jam.setOrganizerMembers(new ArrayList<>(List.of(new Modjam.OrganizerMember("member", "editor")))); + when(jams.findById("jam-1")).thenReturn(Optional.of(jam)); + when(jams.findBySlug("new-jam")).thenReturn(Optional.of(jam)); + when(jams.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @AfterEach + void clearAuthentication() { SecurityContextHolder.clearContext(); } + + @Test + void publicListAlwaysExcludesDraftsAndPendingInviteRecipients() { + Modjam publicJam = new Modjam(); + publicJam.setStatus("ACTIVE"); + publicJam.setPendingOrganizerInvites(List.of(new Modjam.OrganizerInvite("private-user", "Builder", "editor"))); + when(jams.findAll()).thenReturn(List.of(jam, publicJam)); + List result = service.getAllJams(); + assertEquals(1, result.size()); + assertTrue(result.getFirst().getPendingOrganizerInvites().isEmpty()); + assertEquals(1, publicJam.getPendingOrganizerInvites().size()); + verifyNoInteractions(account); + } + + @Test + void anonymousAndHostOwnedApiKeysCannotReadDrafts() { + assertEquals(HttpStatus.NOT_FOUND, assertThrows(ResponseStatusException.class, () -> service.getJamBySlug("new-jam")).getStatusCode()); + User host = user("host", "Host"); + when(account.getCurrentUser()).thenReturn(host); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("host", null, List.of(new SimpleGrantedAuthority("ROLE_API")))); + assertEquals(HttpStatus.NOT_FOUND, assertThrows(ResponseStatusException.class, () -> service.getJamBySlug("new-jam")).getStatusCode()); + } + + @Test + void acceptedMembersCanReadDraftsAndInviteesOnlySeeTheirOwnInvitation() { + when(account.getCurrentUser()).thenReturn(user("member", "Editor")); + assertEquals("jam-1", service.getJamBySlug("new-jam").getId()); + jam.setPendingOrganizerInvites(List.of(new Modjam.OrganizerInvite("invitee", "Invitee", "editor"), new Modjam.OrganizerInvite("other", "Other", "editor"))); + when(account.getCurrentUser()).thenReturn(user("invitee", "Invitee")); + assertEquals(List.of("invitee"), service.getJamBySlug("new-jam").getPendingOrganizerInvites().stream().map(Modjam.OrganizerInvite::userId).toList()); + assertEquals(2, jam.getPendingOrganizerInvites().size()); + } + + @Test + void editorCannotChangeRulesOrSettingsButCanChangeDetails() { + Modjam update = new Modjam(); + BeanUtils.copyProperties(jam, update); + update.setRules("Changed rules"); + assertThrows(ResponseStatusException.class, () -> service.updateJam("jam-1", update, "member")); + verify(jams, never()).save(any()); + update.setRules(jam.getRules()); + update.setAllowPublicVoting(!jam.isAllowPublicVoting()); + assertThrows(ResponseStatusException.class, () -> service.updateJam("jam-1", update, "member")); + update.setAllowPublicVoting(jam.isAllowPublicVoting()); + update.setDescription("Updated description"); + assertEquals("Updated description", service.updateJam("jam-1", update, "member").getDescription()); + var mutation = org.mockito.ArgumentCaptor.forClass(org.springframework.data.mongodb.core.query.Update.class); + verify(mongo).updateFirst(any(org.springframework.data.mongodb.core.query.Query.class), mutation.capture(), eq(Modjam.class)); + var fields = (org.bson.Document) mutation.getValue().getUpdateObject().get("$set"); + assertFalse(fields.containsKey("participantIds")); + assertFalse(fields.containsKey("organizerMembers")); + assertFalse(fields.containsKey("pendingJudgeInviteUsers")); + verify(jams, never()).save(any()); + } + + @Test + void schedulerStatusWritesCannotOverwriteFinalizationOrReopenCompletedJams() { + jam.setStatus("VOTING"); + jam.setStartDate(java.time.Instant.now().minusSeconds(7200)); + jam.setEndDate(java.time.Instant.now().minusSeconds(3600)); + jam.setVotingEndDate(java.time.Instant.now().minusSeconds(1)); + when(jams.findAll()).thenReturn(List.of(jam)); + service.updateJamStates(); + var query = org.mockito.ArgumentCaptor.forClass(org.springframework.data.mongodb.core.query.Query.class); + verify(mongo).updateFirst(query.capture(), any(org.springframework.data.mongodb.core.query.Update.class), eq(Modjam.class)); + assertEquals("VOTING", query.getValue().getQueryObject().get("status")); + assertEquals(false, ((org.bson.Document) query.getValue().getQueryObject().get("finalizationHash")).get("$exists")); + verify(jams, never()).save(any()); + } + + @Test + void judgeInvitationIsBoundToIdentityThroughUsernameChanges() { + jam.setPendingJudgeInvites(new ArrayList<>(List.of("OriginalName"))); + jam.setPendingJudgeInviteUsers(new LinkedHashMap<>(java.util.Map.of("invited-id", "OriginalName"))); + assertThrows(IllegalArgumentException.class, () -> service.acceptJudgeInvite("jam-1", "reused-name-owner", "OriginalName")); + Modjam accepted = new Modjam(); + BeanUtils.copyProperties(jam, accepted); + accepted.setJudgeIds(List.of("invited-id")); + accepted.setPendingJudgeInvites(List.of()); + accepted.setPendingJudgeInviteUsers(java.util.Map.of()); + when(mongo.findAndModify(any(org.springframework.data.mongodb.core.query.Query.class), + any(org.springframework.data.mongodb.core.aggregation.AggregationUpdate.class), + any(org.springframework.data.mongodb.core.FindAndModifyOptions.class), eq(Modjam.class))).thenReturn(accepted); + Modjam result = service.acceptJudgeInvite("jam-1", "invited-id", "RenamedUser"); + assertEquals(List.of("invited-id"), result.getJudgeIds()); + assertTrue(result.getPendingJudgeInvites().isEmpty()); + assertTrue(result.getPendingJudgeInviteUsers().isEmpty()); + } + + @Test + void draftCreationCannotSmuggleMembersJudgesOrOrganizerGrants() { + jam.setParticipantIds(new ArrayList<>(List.of("fake-participant"))); + jam.setJudgeIds(new ArrayList<>(List.of("fake-judge"))); + jam.setPendingJudgeInviteUsers(new LinkedHashMap<>(java.util.Map.of("fake-id", "Fake"))); + when(jams.findBySlug("new-jam")).thenReturn(Optional.empty()); + Modjam created = service.createJam(jam, "host", "Host"); + assertEquals("DRAFT", created.getStatus()); + assertTrue(created.getParticipantIds().isEmpty()); + assertTrue(created.getJudgeIds().isEmpty()); + assertTrue(created.getPendingJudgeInviteUsers().isEmpty()); + assertTrue(created.getOrganizerMembers().isEmpty()); + assertTrue(created.getOrganizerRoles().isEmpty()); + } + + private static User user(String id, String username) { User user = new User(); user.setId(id); user.setUsername(username); return user; } +} diff --git a/backend/src/test/java/net/modtale/service/ModjamServiceTest.java b/backend/src/test/java/net/modtale/service/ModjamServiceTest.java new file mode 100644 index 000000000..1628b3e46 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/ModjamServiceTest.java @@ -0,0 +1,95 @@ +package net.modtale.service; + +import net.modtale.model.jam.Modjam; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.storage.StorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ModjamServiceTest { + + private ModjamService service; + private ModjamRepository modjamRepository; + private StorageService storageService; + + @BeforeEach + void setUp() { + service = new ModjamService(); + modjamRepository = mock(ModjamRepository.class); + storageService = mock(StorageService.class); + ReflectionTestUtils.setField(service, "modjamRepository", modjamRepository); + ReflectionTestUtils.setField(service, "feedService", mock(net.modtale.service.jam.ModjamDiscordFeedService.class)); + ReflectionTestUtils.setField(service, "storageService", storageService); + ReflectionTestUtils.setField(service, "userRepository", mock(UserRepository.class)); + ReflectionTestUtils.setField(service, "organizerService", new net.modtale.service.jam.ModjamOrganizerService( + modjamRepository, mock(UserRepository.class), mock(org.springframework.data.mongodb.core.MongoTemplate.class))); + } + + @Test + void updateJamRejectsNonHostsBeforeApplyingChanges() { + Modjam existing = jamOwnedBy("host-1"); + Modjam update = new Modjam(); + update.setSlug("updated-jam"); + when(modjamRepository.findById("jam-1")).thenReturn(Optional.of(existing)); + + ResponseStatusException error = assertThrows( + ResponseStatusException.class, + () -> service.updateJam("jam-1", update, "other-user") + ); + + assertEquals(HttpStatus.FORBIDDEN, error.getStatusCode()); + verify(modjamRepository, never()).save(any()); + } + + @Test + void updateIconRejectsNonHostsBeforeUploading() { + when(modjamRepository.findById("jam-1")).thenReturn(Optional.of(jamOwnedBy("host-1"))); + MockMultipartFile file = new MockMultipartFile("file", "icon.png", "image/png", new byte[]{1}); + + ResponseStatusException error = assertThrows( + ResponseStatusException.class, + () -> service.updateIcon("jam-1", file, "other-user") + ); + + assertEquals(HttpStatus.FORBIDDEN, error.getStatusCode()); + verify(storageService, never()).upload(any(), any()); + verify(modjamRepository, never()).save(any()); + } + + @Test + void updateBannerRejectsNonHostsBeforeUploading() { + when(modjamRepository.findById("jam-1")).thenReturn(Optional.of(jamOwnedBy("host-1"))); + MockMultipartFile file = new MockMultipartFile("file", "banner.png", "image/png", new byte[]{1}); + + ResponseStatusException error = assertThrows( + ResponseStatusException.class, + () -> service.updateBanner("jam-1", file, "other-user") + ); + + assertEquals(HttpStatus.FORBIDDEN, error.getStatusCode()); + verify(storageService, never()).upload(any(), any()); + verify(modjamRepository, never()).save(any()); + } + + private static Modjam jamOwnedBy(String hostId) { + Modjam jam = new Modjam(); + jam.setId("jam-1"); + jam.setHostId(hostId); + return jam; + } +} diff --git a/backend/src/test/java/net/modtale/service/ModjamSubmissionLifecycleTest.java b/backend/src/test/java/net/modtale/service/ModjamSubmissionLifecycleTest.java new file mode 100644 index 000000000..c9fdb8ce5 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/ModjamSubmissionLifecycleTest.java @@ -0,0 +1,193 @@ +package net.modtale.service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.modtale.exception.ProjectOperationForbiddenException; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamSubmission; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.jam.ModjamSubmissionRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.project.lifecycle.LifecycleService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ModjamSubmissionLifecycleTest { + private ModjamService service; + private ModjamRepository jamRepository; + private ModjamSubmissionRepository submissionRepository; + private ProjectRepository projectRepository; + private UserRepository userRepository; + private LifecycleService lifecycleService; + private Modjam jam; + private Project project; + private User user; + + @BeforeEach + void setUp() { + service = new ModjamService(); + jamRepository = mock(ModjamRepository.class); + submissionRepository = mock(ModjamSubmissionRepository.class); + projectRepository = mock(ProjectRepository.class); + userRepository = mock(UserRepository.class); + lifecycleService = mock(LifecycleService.class); + ReflectionTestUtils.setField(service, "modjamRepository", jamRepository); + ReflectionTestUtils.setField(service, "feedService", mock(net.modtale.service.jam.ModjamDiscordFeedService.class)); + ReflectionTestUtils.setField(service, "submissionRepository", submissionRepository); + ReflectionTestUtils.setField(service, "projectRepository", projectRepository); + ReflectionTestUtils.setField(service, "userRepository", userRepository); + ReflectionTestUtils.setField(service, "lifecycleService", lifecycleService); + ReflectionTestUtils.setField(service, "mongoTemplate", mock(org.springframework.data.mongodb.core.MongoTemplate.class)); + ReflectionTestUtils.setField(service, "customizationService", mock(net.modtale.service.jam.ModjamCustomizationService.class)); + ReflectionTestUtils.setField(service, "accessControlService", mock(net.modtale.service.security.access.AccessControlService.class)); + ReflectionTestUtils.setField(service, "organizerService", new net.modtale.service.jam.ModjamOrganizerService( + jamRepository, userRepository, mock(org.springframework.data.mongodb.core.MongoTemplate.class))); + jam = new Modjam(); + jam.setId("jam-1"); + jam.setStatus("ACTIVE"); + jam.setStartDate(Instant.now().minusSeconds(3600)); + jam.setEndDate(Instant.now().plusSeconds(3600)); + jam.setVotingEndDate(Instant.now().plusSeconds(7200)); + jam.setParticipantIds(new ArrayList<>(List.of("user-1"))); + project = new Project(); + project.setId("project-1"); + project.setAuthorId("user-1"); + project.setStatus(ProjectStatus.DRAFT); + project.setClassification(ProjectClassification.PLUGIN); + user = new User(); + user.setId("user-1"); + when(jamRepository.findById("jam-1")).thenReturn(Optional.of(jam)); + when(projectRepository.findById("project-1")).thenReturn(Optional.of(project)); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(submissionRepository.findByJamIdAndSubmitterId("jam-1", "user-1")).thenReturn(List.of()); + when(projectRepository.findAllById(any())).thenReturn(List.of(project)); + when(submissionRepository.insert(any(ModjamSubmission.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(projectRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void restrictionFailureLeavesDraftUntouched() { + jam.getRestrictions().setRequireSourceRepo(true); + assertThrows(IllegalArgumentException.class, + () -> service.submitProject("jam-1", "project-1", "user-1")); + assertEquals(ProjectStatus.DRAFT, project.getStatus()); + verifyNoInteractions(lifecycleService); + verify(projectRepository, never()).save(any()); + verify(submissionRepository, never()).insert(any(ModjamSubmission.class)); + } + + @Test + void eligibleDraftUsesCanonicalReviewWorkflowBeforeCreatingSubmission() { + doAnswer(invocation -> { + assertEquals(ProjectStatus.DRAFT, project.getStatus()); + verify(submissionRepository, never()).insert(any(ModjamSubmission.class)); + project.setStatus(ProjectStatus.PENDING); + return null; + }).when(lifecycleService).submitProjectForModjam("project-1", user, "jam-1"); + ModjamSubmission submission = service.submitProject("jam-1", "project-1", "user-1"); + verify(lifecycleService).submitProjectForModjam("project-1", user, "jam-1"); + assertEquals("project-1", submission.getProjectId()); + assertTrue(project.getModjamIds().contains("jam-1")); + } + + @Test + void canonicalReviewFailureDoesNotCreateSubmission() { + doThrow(new ProjectOperationForbiddenException("Verify your email address.")) + .when(lifecycleService).submitProjectForModjam("project-1", user, "jam-1"); + assertThrows(ProjectOperationForbiddenException.class, + () -> service.submitProject("jam-1", "project-1", "user-1")); + assertEquals(ProjectStatus.DRAFT, project.getStatus()); + verify(submissionRepository, never()).insert(any(ModjamSubmission.class)); + verify(projectRepository, never()).save(any()); + } + + @Test + void directSubmissionRequiresParticipation() { + jam.setParticipantIds(List.of()); + assertThrows(IllegalArgumentException.class, + () -> service.submitProject("jam-1", "project-1", "user-1")); + verifyNoInteractions(projectRepository, lifecycleService, submissionRepository); + } + + @Test + void staleActiveStatusCannotSubmitAfterDeadline() { + jam.setEndDate(Instant.now().minusSeconds(1)); + assertThrows(IllegalArgumentException.class, + () -> service.submitProject("jam-1", "project-1", "user-1")); + verifyNoInteractions(projectRepository, lifecycleService, submissionRepository); + } + + @Test + void staleUpcomingStatusCanSubmitOnceStartTimePasses() { + jam.setStatus("UPCOMING"); + project.setStatus(ProjectStatus.PUBLISHED); + service.submitProject("jam-1", "project-1", "user-1"); + verify(submissionRepository).insert(any(ModjamSubmission.class)); + verifyNoInteractions(lifecycleService); + } + + @Test + void repeatedSubmissionDoesNotEnterDraftWorkflowAgain() { + ModjamSubmission existing = new ModjamSubmission(); + existing.setProjectId("project-1"); + when(submissionRepository.findByJamIdAndSubmitterId("jam-1", "user-1")).thenReturn(List.of(existing)); + assertThrows(IllegalArgumentException.class, + () -> service.submitProject("jam-1", "project-1", "user-1")); + verifyNoInteractions(lifecycleService); + verify(submissionRepository, never()).insert(any(ModjamSubmission.class)); + } + + @Test + void cannotJoinDraftOrClosedJam() { + for (String status : List.of("DRAFT", "VOTING", "AWAITING_WINNERS", "COMPLETED")) { + jam.setStatus(status); + if (!"DRAFT".equals(status) && !"COMPLETED".equals(status)) { + jam.setEndDate(Instant.now().minusSeconds(10)); + } + assertThrows(IllegalArgumentException.class, + () -> service.participate("jam-1", "user-1"), status); + } + verify(userRepository, never()).save(any()); + verify(jamRepository, never()).save(any()); + } + + @Test + void competingEntryInsertFailsWithConflictWithoutReplacingExistingVotes() { + project.setStatus(ProjectStatus.PUBLISHED); + doThrow(new org.springframework.dao.DuplicateKeyException("existing submission")) + .when(submissionRepository).insert(any(ModjamSubmission.class)); + var error = assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.submitProject("jam-1", "project-1", "user-1")); + assertEquals(org.springframework.http.HttpStatus.CONFLICT, error.getStatusCode()); + verify(submissionRepository, never()).save(any()); + verifyNoInteractions(lifecycleService); + verify(projectRepository, never()).save(any()); + } + + @Test + void submissionIdsEnforcePersonOrProjectUniquenessWithoutANewIndex() { + project.setStatus(ProjectStatus.PUBLISHED); + var first = service.submitProject("jam-1", "project-1", "user-1"); + project.setId("project-2"); + when(projectRepository.findById("project-2")).thenReturn(Optional.of(project)); + var samePerson = service.submitProject("jam-1", "project-2", "user-1"); + assertEquals(first.getId(), samePerson.getId()); + jam.setOneEntryPerPerson(false); + var perProject = service.submitProject("jam-1", "project-2", "user-1"); + assertNotEquals(first.getId(), perProject.getId()); + assertTrue(perProject.getId().matches("modjam-[a-f0-9]{64}")); + verify(submissionRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/net/modtale/service/ModjamVoteVisibilityTest.java b/backend/src/test/java/net/modtale/service/ModjamVoteVisibilityTest.java new file mode 100644 index 000000000..e9f33195a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/ModjamVoteVisibilityTest.java @@ -0,0 +1,227 @@ +package net.modtale.service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamSubmission; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.jam.ModjamSubmissionRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.jam.ModjamOrganizerService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ModjamVoteVisibilityTest { + private ModjamService service; + private ModjamRepository jams; + private ModjamSubmissionRepository submissions; + private ProjectRepository projects; + private AccountService account; + private AccessControlService access; + private Modjam jam; + private ModjamSubmission submission; + private Project project; + + @BeforeEach + void setUp() { + service = new ModjamService(); + jams = mock(ModjamRepository.class); + submissions = mock(ModjamSubmissionRepository.class); + projects = mock(ProjectRepository.class); + account = mock(AccountService.class); + access = mock(AccessControlService.class); + ReflectionTestUtils.setField(service, "modjamRepository", jams); + ReflectionTestUtils.setField(service, "feedService", mock(net.modtale.service.jam.ModjamDiscordFeedService.class)); + ReflectionTestUtils.setField(service, "submissionRepository", submissions); + ReflectionTestUtils.setField(service, "projectRepository", projects); + ReflectionTestUtils.setField(service, "accountService", account); + ReflectionTestUtils.setField(service, "accessControlService", access); + var votePersistence = mock(net.modtale.service.jam.ModjamVotePersistence.class); + ReflectionTestUtils.setField(service, "votePersistence", votePersistence); + when(votePersistence.replaceBallot(any(), any(), any())).thenAnswer(invocation -> { + ModjamSubmission.Vote vote = invocation.getArgument(2); + submission.getVotes().removeIf(existing -> vote.getVoterId().equals(existing.getVoterId()) + && vote.getCategoryId().equals(existing.getCategoryId())); + submission.getVotes().add(vote); + submission.setVoteRevision(submission.getVoteRevision() + 1); + return submission; + }); + ReflectionTestUtils.setField(service, "organizerService", new ModjamOrganizerService( + jams, mock(UserRepository.class), mock(MongoTemplate.class))); + jam = new Modjam(); + jam.setId("jam-1"); + jam.setHostId("host-1"); + jam.setStatus("VOTING"); + jam.setStartDate(Instant.now().minusSeconds(7200)); + jam.setEndDate(Instant.now().minusSeconds(3600)); + jam.setVotingEndDate(Instant.now().plusSeconds(3600)); + jam.setAllowPublicVoting(true); + jam.setCategories(List.of(new Modjam.Category("quality", "Quality", "", 10))); + project = new Project(); + project.setId("project-1"); + project.setAuthorId("author-1"); + project.setStatus(ProjectStatus.PUBLISHED); + submission = new ModjamSubmission(); + submission.setId("submission-1"); + submission.setJamId("jam-1"); + submission.setProjectId("project-1"); + submission.setSubmitterId("author-1"); + submission.setVotes(new ArrayList<>(List.of( + new ModjamSubmission.Vote("vote-1", "viewer-1", "quality", 5, false), + new ModjamSubmission.Vote("vote-2", "other-1", "quality", 9, false)))); + submission.setCategoryScores(Map.of("quality", 7.0)); + submission.setJudgeCategoryScores(Map.of("quality", 9.0)); + submission.setTotalScore(7.0); + submission.setTotalJudgeScore(9.0); + submission.setTotalPublicScore(7.0); + submission.setRank(1); + when(jams.findById("jam-1")).thenReturn(Optional.of(jam)); + when(submissions.findByJamId("jam-1")).thenAnswer(invocation -> new ArrayList<>(List.of(submission))); + when(submissions.findById("submission-1")).thenReturn(Optional.of(submission)); + when(projects.findById("project-1")).thenReturn(Optional.of(project)); + when(projects.findAllById(any())).thenReturn(List.of(project)); + } + + @Test + void anonymousReadRedactsScoresAndBallotsWithoutChangingStoredDocument() { + var response = service.getSubmissions("jam-1").getFirst(); + assertNotSame(submission, response); + assertNull(response.getTotalScore()); + assertNull(response.getTotalJudgeScore()); + assertNull(response.getTotalPublicScore()); + assertNull(response.getCategoryScores()); + assertNull(response.getJudgeCategoryScores()); + assertNull(response.getRank()); + assertTrue(response.getVotes().isEmpty()); + assertEquals(7.0, submission.getTotalScore()); + assertEquals(2, submission.getVotes().size()); + } + + @Test + void browserViewerRetainsOnlyTheirOwnBallot() { + when(account.getCurrentUser()).thenReturn(user("viewer-1")); + var response = service.getSubmissions("jam-1").getFirst(); + assertEquals(1, response.getVotes().size()); + assertEquals("viewer-1", response.getVotes().getFirst().getVoterId()); + assertNull(response.getTotalScore()); + } + + @Test + void readOnlyOrganizerCanViewResultsButCannotJudge() { + jam.setAllowPublicVoting(false); + jam.setOrganizerRoles(List.of(new Modjam.OrganizerRole("view", "Viewer", "#123456", + Set.of(Modjam.JamPermission.VIEW_RESULTS)))); + jam.setOrganizerMembers(List.of(new Modjam.OrganizerMember("viewer-1", "view"))); + when(account.getCurrentUser()).thenReturn(user("viewer-1")); + assertEquals(7.0, service.getSubmissions("jam-1").getFirst().getTotalScore()); + assertEquals(HttpStatus.FORBIDDEN, assertThrows(ResponseStatusException.class, + () -> service.vote("jam-1", "submission-1", "quality", 8, "viewer-1")).getStatusCode()); + } + + @Test + void apiKeyCannotInheritOwnersPrivateBallotsOrHostPrivileges() { + when(account.getCurrentUser()).thenReturn(user("host-1")); + when(access.isApiKey(any())).thenReturn(true); + var response = service.getSubmissions("jam-1").getFirst(); + assertNull(response.getTotalScore()); + assertTrue(response.getVotes().isEmpty()); + jam.setHideSubmissions(true); + jam.setEndDate(Instant.now().plusSeconds(3600)); + assertTrue(service.getSubmissions("jam-1").isEmpty()); + } + + @Test + void staleAwaitingStatusDoesNotRevealScoresBeforeDeadline() { + jam.setStatus("AWAITING_WINNERS"); + assertNull(service.getSubmissions("jam-1").getFirst().getTotalScore()); + } + + @Test + void resultsRevealAtDeadlineWithoutExposingOtherBallots() { + jam.setVotingEndDate(Instant.now().minusSeconds(1)); + var response = service.getSubmissions("jam-1").getFirst(); + assertEquals(7.0, response.getTotalScore()); + assertTrue(response.getVotes().isEmpty()); + } + + @Test + void staleVotingStatusCannotExposeSecretEntriesDuringSubmissionPhase() { + jam.setHideSubmissions(true); + jam.setEndDate(Instant.now().plusSeconds(3600)); + assertTrue(service.getSubmissions("jam-1").isEmpty()); + } + + @Test + void rejectsCrossJamSubmissionBeforeAnyMutation() { + submission.setJamId("different-jam"); + assertEquals(HttpStatus.NOT_FOUND, assertThrows(ResponseStatusException.class, + () -> service.vote("jam-1", "submission-1", "quality", 8, "viewer-1")).getStatusCode()); + verify(projects, never()).findById(any()); + verify(submissions, never()).save(any()); + } + + @Test + void teammateCannotVoteOnOwnEntry() { + project.setTeamMembers(List.of(new Project.ProjectMember("viewer-1", "developer"))); + assertEquals(HttpStatus.FORBIDDEN, assertThrows(ResponseStatusException.class, + () -> service.vote("jam-1", "submission-1", "quality", 8, "viewer-1")).getStatusCode()); + verify(submissions, never()).save(any()); + } + + @Test + void rejectsInvalidCategoryAndScoreWithoutLoadingSubmission() { + for (int score : List.of(0, -1, 11, Integer.MAX_VALUE)) { + assertEquals(HttpStatus.BAD_REQUEST, assertThrows(ResponseStatusException.class, + () -> service.vote("jam-1", "submission-1", "quality", score, "viewer-1")).getStatusCode()); + } + assertThrows(ResponseStatusException.class, + () -> service.vote("jam-1", "submission-1", "foreign-category", 5, "viewer-1")); + verifyNoInteractions(submissions); + } + + @Test + void staleActiveStatusRejectsVotesAfterDeadline() { + jam.setStatus("ACTIVE"); + jam.setVotingEndDate(Instant.now().minusSeconds(1)); + assertEquals(HttpStatus.CONFLICT, assertThrows(ResponseStatusException.class, + () -> service.vote("jam-1", "submission-1", "quality", 8, "viewer-1")).getStatusCode()); + verifyNoInteractions(submissions); + } + + @Test + void voteReplayReplacesOnlyOwnCategoryAndReturnsRedactedCopy() { + jam.setStatus("UPCOMING"); + var response = service.vote("jam-1", "submission-1", "quality", 8, "viewer-1"); + assertEquals(2, submission.getVotes().size()); + assertEquals(1, response.getVotes().size()); + assertEquals(8, response.getVotes().getFirst().getScore()); + assertNull(response.getTotalScore()); + service.vote("jam-1", "submission-1", "quality", 6, "viewer-1"); + assertEquals(2, submission.getVotes().size()); + assertEquals(9, submission.getVotes().stream().filter(vote -> "other-1".equals(vote.getVoterId())).findFirst().orElseThrow().getScore()); + } + + private static User user(String id) { + User user = new User(); + user.setId(id); + return user; + } +} diff --git a/backend/src/test/java/net/modtale/service/jam/DiscordWebhookFeedTransportTest.java b/backend/src/test/java/net/modtale/service/jam/DiscordWebhookFeedTransportTest.java new file mode 100644 index 000000000..75d885265 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/jam/DiscordWebhookFeedTransportTest.java @@ -0,0 +1,83 @@ +package net.modtale.service.jam; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import net.modtale.config.properties.AppModjamDiscordFeedProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +class DiscordWebhookFeedTransportTest { + private static final URI FAKE_DESTINATION = URI.create("https://discord.com/api/webhooks/1/test-only-placeholder?wait=true"); + + @Test + void sendsJsonAndWaitsForAcknowledgementUsingOnlyFakeHttp() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + server.expect(requestTo(FAKE_DESTINATION)).andExpect(method(HttpMethod.POST)) + .andExpect(header("Content-Type", "application/json")) + .andExpect(content().json("{\"content\":\"Test\",\"allowed_mentions\":{\"parse\":[]}}")) + .andRespond(withSuccess("{\"id\":\"fake-message\"}", MediaType.APPLICATION_JSON)); + var result = new DiscordWebhookFeedTransport(rest).send(FAKE_DESTINATION, + Map.of("content", "Test", "allowed_mentions", Map.of("parse", List.of()))); + assertTrue(result.accepted()); + server.verify(); + } + + @Test + void rateLimitUsesFractionalSecondsFromRetryAfterWithoutLeakingException() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + server.expect(requestTo(FAKE_DESTINATION)).andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS) + .header("Retry-After", "12.345").body("{\"retry_after\":12.345}")); + var result = new DiscordWebhookFeedTransport(rest).send(FAKE_DESTINATION, Map.of("content", "Test")); + assertEquals(429, result.status()); + assertEquals(Duration.ofMillis(12345), result.retryAfter()); + assertFalse(result.accepted()); + server.verify(); + } + + @Test + void malformedRetryHeadersUseSafeBackoffAndResetAfterIsSupported() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Retry-After", "invalid"); + assertEquals(Duration.ZERO, DiscordWebhookFeedTransport.retryAfter(headers)); + headers.set("Retry-After", "-3"); + assertEquals(Duration.ZERO, DiscordWebhookFeedTransport.retryAfter(headers)); + headers.remove("Retry-After"); + headers.set("X-RateLimit-Reset-After", "0.0012"); + assertEquals(Duration.ofMillis(2), DiscordWebhookFeedTransport.retryAfter(headers)); + } + + @Test + void defaultTransportHasBoundedConnectionAndReadTimeoutsWithoutSending() { + var transport = new DiscordWebhookFeedTransport(); + RestTemplate rest = (RestTemplate) ReflectionTestUtils.getField(transport, "rest"); + assertNotNull(rest); + assertEquals(3000, ReflectionTestUtils.getField(rest.getRequestFactory(), "connectTimeout")); + assertEquals(10000, ReflectionTestUtils.getField(rest.getRequestFactory(), "readTimeout")); + } + + @Test + void propertyDefaultsAreDisabledAndSecretIsNotIncludedInToString() { + var binder = new Binder(new MapConfigurationPropertySource( + Map.of("app.modjam-discord-feed.url", FAKE_DESTINATION.toString()))); + var properties = binder.bind("app.modjam-discord-feed", AppModjamDiscordFeedProperties.class) + .orElseThrow(AssertionError::new); + assertFalse(properties.enabled()); + assertFalse(properties.toString().contains("test-only-placeholder")); + } +} diff --git a/backend/src/test/java/net/modtale/service/jam/ModjamCustomizationServiceTest.java b/backend/src/test/java/net/modtale/service/jam/ModjamCustomizationServiceTest.java new file mode 100644 index 000000000..f1ec591fd --- /dev/null +++ b/backend/src/test/java/net/modtale/service/jam/ModjamCustomizationServiceTest.java @@ -0,0 +1,96 @@ +package net.modtale.service.jam; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import net.modtale.model.jam.Modjam; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.project.ProjectVersion; +import net.modtale.service.project.catalog.GameVersionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ModjamCustomizationServiceTest { + private ModjamCustomizationService service; + + @BeforeEach + void setUp() { + GameVersionService versions = mock(GameVersionService.class); + when(versions.getCatalog()).thenReturn(new GameVersionService.GameVersionCatalog( + List.of("0.3.0", "0.2.0", "0.1.0"), List.of(), List.of("0.3.0", "0.2.0", "0.1.0"), List.of())); + service = new ModjamCustomizationService(versions); + } + + @Test + void acceptsSafeMarkdownPresentationAndRejectsDocumentControlAndNetworkLoads() { + assertDoesNotThrow(() -> ModjamCustomizationService.validateCss("h1, h2 { color: #aabbcc; margin: 1rem 0; } p { line-height: 1.6; }")); + for (String css : List.of( + "body { color: red; }", "h1 { background-color: url(https://example.test/track); }", + "@import 'https://example.test/track';", "h1 { position: fixed; }", + "h1 { color: var(--secret); }", "h1 { color: red !important; }", + "h1 { color: red; } trailing", "h1 { color: red; } ")) { + assertThrows(IllegalArgumentException.class, () -> ModjamCustomizationService.validateCss(css), css); + } + } + + @Test + void validatesInclusiveRangeUsingCanonicalCatalogRatherThanLexicalOrder() { + Modjam.Restrictions range = range("0.1.0", "0.2.0"); + assertDoesNotThrow(() -> service.validateProjectVersions(range, project("0.1.0", ProjectVersion.ReviewStatus.APPROVED))); + assertDoesNotThrow(() -> service.validateProjectVersions(range, project("0.2.0", ProjectVersion.ReviewStatus.APPROVED))); + assertThrows(IllegalArgumentException.class, () -> service.validateProjectVersions(range, project("0.3.0", ProjectVersion.ReviewStatus.APPROVED))); + assertThrows(IllegalArgumentException.class, () -> service.validateVersionRestriction(range("0.3.0", "0.1.0"))); + assertThrows(IllegalArgumentException.class, () -> service.validateVersionRestriction(range("unknown", "0.2.0"))); + } + + @Test + void rejectsMixedExactAndRangeAndNonDownloadableReleases() { + Modjam.Restrictions restriction = new Modjam.Restrictions(); + restriction.setAllowedGameVersions(List.of("0.2.0")); + for (ProjectVersion.ReviewStatus status : List.of(ProjectVersion.ReviewStatus.REJECTED, ProjectVersion.ReviewStatus.SCHEDULED, ProjectVersion.ReviewStatus.PENDING)) { + assertThrows(IllegalArgumentException.class, () -> service.validateProjectVersions(restriction, project("0.2.0", status))); + } + Project draft = project("0.2.0", ProjectVersion.ReviewStatus.PENDING); + draft.setStatus(ProjectStatus.DRAFT); + assertDoesNotThrow(() -> service.validateProjectVersions(restriction, draft)); + restriction.setMinimumGameVersion("0.1.0"); + assertThrows(IllegalArgumentException.class, () -> service.validateVersionRestriction(restriction)); + } + + @Test + void validatesSlugLengthAndCategoriesEvenForDrafts() { + Modjam jam = new Modjam(); + jam.setTitle("New Jam"); + jam.setSlug("new-jam"); + assertDoesNotThrow(() -> service.validate(jam)); + jam.setSlug("a"); + assertThrows(IllegalArgumentException.class, () -> service.validate(jam)); + jam.setSlug("new-jam"); + jam.setCategories(List.of(new Modjam.Category("same", "Fun", "", 10), new Modjam.Category("same", "Style", "", 10))); + assertThrows(IllegalArgumentException.class, () -> service.validate(jam)); + jam.setCategories(List.of(new Modjam.Category("id", "Fun", "", 0))); + assertThrows(IllegalArgumentException.class, () -> service.validate(jam)); + } + + private static Modjam.Restrictions range(String minimum, String maximum) { + Modjam.Restrictions range = new Modjam.Restrictions(); + range.setMinimumGameVersion(minimum); + range.setMaximumGameVersion(maximum); + return range; + } + + private static Project project(String gameVersion, ProjectVersion.ReviewStatus status) { + Project project = new Project(); + project.setStatus(ProjectStatus.PUBLISHED); + ProjectVersion version = new ProjectVersion(); + version.setGameVersions(List.of(gameVersion)); + version.setReviewStatus(status); + version.setFileUrl("/uploads/project.jar"); + project.setVersions(List.of(version)); + return project; + } +} diff --git a/backend/src/test/java/net/modtale/service/jam/ModjamDiscordFeedServiceTest.java b/backend/src/test/java/net/modtale/service/jam/ModjamDiscordFeedServiceTest.java new file mode 100644 index 000000000..6271ddfa1 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/jam/ModjamDiscordFeedServiceTest.java @@ -0,0 +1,410 @@ +package net.modtale.service.jam; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.config.properties.AppModjamDiscordFeedProperties; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.ModjamDiscordEvent; +import net.modtale.model.jam.ModjamDiscordEvent.Milestone; +import net.modtale.model.jam.ModjamDiscordEvent.State; +import net.modtale.repository.jam.ModjamDiscordOutboxStore; +import net.modtale.repository.jam.ModjamRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; + +class ModjamDiscordFeedServiceTest { + // This placeholder is only passed to fake/mock transport; no test opens network sockets. + private static final String FAKE_DESTINATION = "https://discord.com/api/webhooks/1/test-only-placeholder"; + private final MutableClock clock = new MutableClock(Instant.parse("2026-10-01T12:00:00Z")); + private final MemoryOutbox outbox = new MemoryOutbox(); + private final ModjamRepository jams = mock(ModjamRepository.class); + private final DiscordFeedTransport transport = mock(DiscordFeedTransport.class); + private Modjam jam; + private ModjamDiscordFeedService service; + + @BeforeEach + void setUp() { + jam = new Modjam(); + jam.setId("jam-test-1"); + jam.setSlug("test-jam"); + jam.setTitle("Test jam"); + jam.setCreatedAt(clock.instant().minusSeconds(3600)); + jam.setPublishedAt(clock.instant()); + jam.setUpdatedAt(clock.instant()); + jam.setStartDate(clock.instant().plusSeconds(60)); + jam.setEndDate(clock.instant().plusSeconds(120)); + jam.setVotingEndDate(clock.instant().plusSeconds(180)); + when(jams.findById(jam.getId())).thenReturn(Optional.of(jam)); + when(jams.findByStatusIn(any())).thenAnswer(invocation -> "DRAFT".equals(jam.getStatus()) ? List.of() : List.of(jam)); + when(transport.send(any(), any())).thenReturn(new DiscordFeedTransport.Result(200, Duration.ZERO)); + service = service(true, FAKE_DESTINATION); + } + + @Test + void draftIsSecretUntilPublishedAndCreationIsDeduplicated() { + service.recordMilestones(jam); + service.reconcileMilestones(); + service.deliverPending(); + assertTrue(outbox.events.isEmpty()); + verifyNoInteractions(transport); + + jam.setStatus("UPCOMING"); + service.recordMilestones(jam); + service.recordMilestones(jam); + service.reconcileMilestones(); + assertEquals(List.of(Milestone.CREATED), outbox.milestones()); + service.deliverPending(); + service.reconcileMilestones(); + service(true, FAKE_DESTINATION).deliverPending(); + verify(transport, times(1)).send(any(), any()); + assertEquals(State.DELIVERED, outbox.events.values().iterator().next().getState()); + } + + @Test + void openingsAndWinnerAnnouncementSurviveRestartsAndRepeatedSchedulerPasses() { + jam.setStatus("UPCOMING"); + service.reconcileMilestones(); + clock.advance(Duration.ofSeconds(60)); + service.reconcileMilestones(); + assertEquals(List.of(Milestone.CREATED, Milestone.SUBMISSIONS_OPENED), outbox.milestones()); + clock.advance(Duration.ofSeconds(60)); + service.reconcileMilestones(); + clock.advance(Duration.ofSeconds(60)); + jam.setStatus("AWAITING_WINNERS"); + service.reconcileMilestones(); + assertEquals(3, outbox.events.size()); + assertFalse(outbox.milestones().contains(Milestone.WINNERS_ANNOUNCED)); + jam.setStatus("COMPLETED"); + jam.setUpdatedAt(clock.instant()); + jam.setWinnersAnnouncedAt(clock.instant()); + service.recordMilestones(jam); + ModjamDiscordFeedService restarted = service(true, FAKE_DESTINATION); + for (int i = 0; i < 3; i++) { + restarted.reconcileMilestones(); + restarted.deliverPending(); + } + assertEquals(4, outbox.events.size()); + assertTrue(outbox.events.values().stream().allMatch(event -> event.getState() == State.DELIVERED)); + verify(transport, times(4)).send(any(), any()); + } + + @Test + void concurrentVotingSuppressesSeparateOpening() { + jam.setStatus("ACTIVE"); + jam.setAllowConcurrentVoting(true); + clock.advance(Duration.ofMinutes(4)); + service.reconcileMilestones(); + service.reconcileMilestones(); + assertEquals(List.of(Milestone.CREATED, Milestone.SUBMISSIONS_OPENED), outbox.milestones()); + } + + @Test + void sameSubmissionAndVotingOpeningDoesNotCreateSeparateVoteEvent() { + jam.setStatus("ACTIVE"); + jam.setEndDate(jam.getStartDate()); + clock.advance(Duration.ofMinutes(4)); + service.recordMilestones(jam); + assertFalse(outbox.milestones().contains(Milestone.VOTING_OPENED)); + } + + @Test + void disableFlagAndMissingOrInvalidDestinationMakeNoChangesOrTraffic() { + jam.setStatus("COMPLETED"); + for (ModjamDiscordFeedService disabled : List.of(service(false, FAKE_DESTINATION), + service(true, ""), service(true, "https://untrusted.test/hook"), + service(true, "http://discord.com/api/webhooks/1/test-only-placeholder"))) { + disabled.recordMilestones(jam); + disabled.reconcileMilestones(); + disabled.deliverPending(); + } + assertTrue(outbox.events.isEmpty()); + verifyNoInteractions(jams, transport); + } + + @Test + void failedSendIsRetriedWithoutResettingDeliveryStateOrRetryTime() { + jam.setStatus("UPCOMING"); + when(transport.send(any(), any())).thenThrow(new IllegalStateException("simulated timeout")) + .thenReturn(new DiscordFeedTransport.Result(200, Duration.ZERO)); + service.recordMilestones(jam); + service.deliverPending(); + ModjamDiscordEvent event = outbox.events.values().iterator().next(); + assertEquals(State.PENDING, event.getState()); + Instant nextAttempt = event.getNextAttemptAt(); + service.recordMilestones(jam); + assertEquals(nextAttempt, event.getNextAttemptAt()); + ModjamDiscordFeedService restarted = service(true, FAKE_DESTINATION); + restarted.deliverPending(); + verify(transport, times(1)).send(any(), any()); + clock.advance(Duration.ofSeconds(30)); + restarted.deliverPending(); + assertEquals(State.DELIVERED, event.getState()); + restarted.deliverPending(); + verify(transport, times(2)).send(any(), any()); + } + + @Test + void rateLimitBackoffIsSharedDurablyAcrossMilestonesAndRestarts() { + jam.setStatus("ACTIVE"); + clock.advance(Duration.ofSeconds(60)); + when(transport.send(any(), any())).thenReturn(new DiscordFeedTransport.Result(429, Duration.ofSeconds(90))) + .thenReturn(new DiscordFeedTransport.Result(200, Duration.ZERO)); + service.recordMilestones(jam); + service.deliverPending(); + service(true, FAKE_DESTINATION).deliverPending(); + clock.advance(Duration.ofSeconds(89)); + service.deliverPending(); + verify(transport, times(1)).send(any(), any()); + clock.advance(Duration.ofSeconds(1)); + service(true, FAKE_DESTINATION).deliverPending(); + verify(transport, times(3)).send(any(), any()); + assertTrue(outbox.events.values().stream().allMatch(event -> event.getState() == State.DELIVERED)); + } + + @Test + void abandonedLeaseRecoversAfterRestartAndOldLeaseCannotAcknowledgeNewWork() { + jam.setStatus("UPCOMING"); + service.recordMilestones(jam); + ModjamDiscordEvent abandoned = outbox.claimNext(clock.instant(), Duration.ofMinutes(2)).orElseThrow(); + service(true, FAKE_DESTINATION).deliverPending(); + verifyNoInteractions(transport); + clock.advance(Duration.ofMinutes(2)); + ModjamDiscordEvent reclaimed = outbox.claimNext(clock.instant(), Duration.ofMinutes(2)).orElseThrow(); + outbox.delivered(abandoned, clock.instant()); + assertEquals(State.PENDING, outbox.events.get(abandoned.getId()).getState()); + outbox.retry(reclaimed, clock.instant(), null); + service(true, FAKE_DESTINATION).deliverPending(); + verify(transport, times(1)).send(any(), any()); + } + + @Test + void concurrentWorkersDoNotSendTheSameMilestone() throws Exception { + jam.setStatus("UPCOMING"); + service.recordMilestones(jam); + CountDownLatch sending = new CountDownLatch(1); + CountDownLatch finish = new CountDownLatch(1); + AtomicInteger sends = new AtomicInteger(); + DiscordFeedTransport blockedTransport = (uri, body) -> { + sends.incrementAndGet(); + sending.countDown(); + try { assertTrue(finish.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException exception) { throw new IllegalStateException(exception); } + return new DiscordFeedTransport.Result(200, Duration.ZERO); + }; + ModjamDiscordFeedService worker = new ModjamDiscordFeedService(jams, outbox, blockedTransport, + new AppModjamDiscordFeedProperties(true, FAKE_DESTINATION), + new AppFrontendProperties("https://modtale.test"), clock); + try (var threads = Executors.newFixedThreadPool(2)) { + var first = threads.submit(worker::deliverPending); + assertTrue(sending.await(5, TimeUnit.SECONDS)); + threads.submit(worker::deliverPending).get(5, TimeUnit.SECONDS); + finish.countDown(); + first.get(5, TimeUnit.SECONDS); + } finally { finish.countDown(); } + assertEquals(1, sends.get()); + } + + @Test + void queuedPublicEventIsWithheldIfJamIsUnpublishedAndUsesCurrentPublicDetails() { + jam.setStatus("UPCOMING"); + service.recordMilestones(jam); + jam.setStatus("DRAFT"); + jam.setTitle("Private draft title"); + service.deliverPending(); + verifyNoInteractions(transport); + jam.setStatus("UPCOMING"); + jam.setTitle("Published @everyone <@123> `title`"); + clock.advance(Duration.ofMinutes(1)); + service.deliverPending(); + var body = org.mockito.ArgumentCaptor.forClass(Map.class); + var destination = org.mockito.ArgumentCaptor.forClass(URI.class); + verify(transport).send(destination.capture(), body.capture()); + assertEquals("wait=true", destination.getValue().getQuery()); + assertEquals(Map.of("parse", List.of(), "users", List.of(), "roles", List.of(), "replied_user", false), + body.getValue().get("allowed_mentions")); + String content = (String) body.getValue().get("content"); + assertFalse(content.contains("Private draft title")); + assertFalse(content.contains("@everyone")); + assertTrue(content.contains("https://modtale.test/jam/test-jam")); + } + + @Test + void deletionDiscardsPendingEventWithoutSending() { + jam.setStatus("UPCOMING"); + service.recordMilestones(jam); + when(jams.findById(jam.getId())).thenReturn(Optional.empty()); + service.deliverPending(); + assertEquals(State.DISCARDED, outbox.events.values().iterator().next().getState()); + verifyNoInteractions(transport); + } + + @Test + void enqueueFailureDoesNotBreakLifecycleAndScheduledReconciliationRepairsIt() { + jam.setStatus("UPCOMING"); + ModjamDiscordOutboxStore failed = mock(ModjamDiscordOutboxStore.class); + when(failed.activationTime(any())).thenReturn(clock.instant()); + doThrow(new IllegalStateException("DB temporarily unavailable")).when(failed).enqueue(any()); + var failureTolerant = new ModjamDiscordFeedService(jams, failed, transport, + new AppModjamDiscordFeedProperties(true, FAKE_DESTINATION), + new AppFrontendProperties("https://modtale.test"), clock); + assertDoesNotThrow(() -> failureTolerant.recordMilestones(jam)); + assertTrue(outbox.events.isEmpty()); + service.reconcileMilestones(); + assertEquals(1, outbox.events.size()); + } + + @Test + void firstEnableSkipsHistoricalJamsAndOldDraftCreationDoesNotHideNewPublication() { + jam.setStatus("COMPLETED"); + jam.setPublishedAt(null); + jam.setStartDate(clock.instant().minusSeconds(180)); + jam.setEndDate(clock.instant().minusSeconds(120)); + jam.setVotingEndDate(clock.instant().minusSeconds(60)); + jam.setUpdatedAt(clock.instant().minusSeconds(30)); + jam.setWinnersAnnouncedAt(clock.instant().minusSeconds(30)); + service.reconcileMilestones(); + service.deliverPending(); + assertTrue(outbox.events.isEmpty()); + verifyNoInteractions(transport); + + jam.setStatus("UPCOMING"); + jam.setPublishedAt(clock.instant()); + jam.setStartDate(clock.instant().plusSeconds(60)); + jam.setEndDate(clock.instant().plusSeconds(120)); + jam.setVotingEndDate(clock.instant().plusSeconds(180)); + service.recordMilestones(jam); + assertEquals(List.of(Milestone.CREATED), outbox.milestones()); + } + + @Test + void persistedActivationRepairsMilestonesThatOccurredDuringDowntime() { + jam.setStatus("ACTIVE"); + clock.advance(Duration.ofMinutes(3)); + var restarted = service(true, FAKE_DESTINATION); + restarted.reconcileMilestones(); + restarted.deliverPending(); + assertEquals(3, outbox.events.size()); + verify(transport, times(3)).send(any(), any()); + } + + @Test + void editingOldCompletedJamDoesNotInventFreshWinnerAnnouncement() { + jam.setStatus("COMPLETED"); + jam.setPublishedAt(clock.instant().minusSeconds(3600)); + jam.setStartDate(clock.instant().minusSeconds(180)); + jam.setEndDate(clock.instant().minusSeconds(120)); + jam.setVotingEndDate(clock.instant().minusSeconds(60)); + jam.setWinnersAnnouncedAt(clock.instant().minusSeconds(30)); + jam.setUpdatedAt(clock.instant()); + service.recordMilestones(jam); + service.reconcileMilestones(); + assertTrue(outbox.events.isEmpty()); + jam.setWinnersAnnouncedAt(null); // Legacy completed records have no server announcement timestamp. + service.reconcileMilestones(); + assertTrue(outbox.events.isEmpty()); + } + + @Test + void temporaryInitialActivationFailureDoesNotMoveCutoffPastNewPublication() { + Instant requestedAt = clock.instant(); + AtomicInteger attempts = new AtomicInteger(); + MemoryOutbox temporarilyUnavailable = new MemoryOutbox() { + @Override public synchronized Instant activationTime(Instant requested) { + if (attempts.incrementAndGet() == 1) throw new IllegalStateException("temporary activation failure"); + assertEquals(requestedAt, requested); + return super.activationTime(requested); + } + }; + var recovering = new ModjamDiscordFeedService(jams, temporarilyUnavailable, transport, + new AppModjamDiscordFeedProperties(true, FAKE_DESTINATION), + new AppFrontendProperties("https://modtale.test"), clock); + jam.setStatus("UPCOMING"); + clock.advance(Duration.ofMinutes(1)); + recovering.recordMilestones(jam); + assertEquals(List.of(Milestone.CREATED, Milestone.SUBMISSIONS_OPENED), temporarilyUnavailable.milestones()); + } + + private ModjamDiscordFeedService service(boolean enabled, String destination) { + return new ModjamDiscordFeedService(jams, outbox, transport, + new AppModjamDiscordFeedProperties(enabled, destination), + new AppFrontendProperties("https://modtale.test"), clock); + } + + private static class MutableClock extends Clock { + private Instant now; + MutableClock(Instant now) { this.now = now; } + void advance(Duration duration) { now = now.plus(duration); } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return now; } + } + + /** Persisted fake state is deliberately shared by independently constructed service instances. */ + private static class MemoryOutbox implements ModjamDiscordOutboxStore { + final Map events = new LinkedHashMap<>(); + private Instant blockedUntil; + private Instant activatedAt; + @Override public synchronized void enqueue(ModjamDiscordEvent event) { events.putIfAbsent(event.getId(), event); } + List milestones() { return events.values().stream().map(ModjamDiscordEvent::getMilestone).toList(); } + @Override public synchronized Optional claimNext(Instant now, Duration lease) { + if (blockedUntil != null && now.isBefore(blockedUntil)) return Optional.empty(); + Optional next = events.values().stream() + .filter(event -> event.getState() == State.PENDING && !now.isBefore(event.getNextAttemptAt())) + .filter(event -> event.getLeaseUntil() == null || !now.isBefore(event.getLeaseUntil())) + .min(Comparator.comparing(ModjamDiscordEvent::getOccurredAt).thenComparing(ModjamDiscordEvent::getId)); + return next.map(event -> { + event.setLeaseToken(UUID.randomUUID().toString()); + event.setLeaseUntil(now.plus(lease)); + event.setAttempts(event.getAttempts() + 1); + ModjamDiscordEvent snapshot = new ModjamDiscordEvent(); + BeanUtils.copyProperties(event, snapshot); + return snapshot; + }); + } + private ModjamDiscordEvent owned(ModjamDiscordEvent event) { + ModjamDiscordEvent current = events.get(event.getId()); + return current != null && current.getState() == State.PENDING + && java.util.Objects.equals(event.getLeaseToken(), current.getLeaseToken()) ? current : null; + } + private void clearLease(ModjamDiscordEvent current) { current.setLeaseToken(null); current.setLeaseUntil(null); } + @Override public synchronized void delivered(ModjamDiscordEvent event, Instant now) { + ModjamDiscordEvent current = owned(event); + if (current != null) { current.setState(State.DELIVERED); current.setDeliveredAt(now); clearLease(current); } + } + @Override public synchronized void retry(ModjamDiscordEvent event, Instant nextAttempt, Integer status) { + ModjamDiscordEvent current = owned(event); + if (current != null) { current.setNextAttemptAt(nextAttempt); current.setLastStatus(status); clearLease(current); } + } + @Override public synchronized void discard(ModjamDiscordEvent event) { + ModjamDiscordEvent current = owned(event); + if (current != null) { current.setState(State.DISCARDED); clearLease(current); } + } + @Override public synchronized void pauseUntil(Instant until) { + if (blockedUntil == null || blockedUntil.isBefore(until)) blockedUntil = until; + } + @Override public synchronized Instant activationTime(Instant now) { + if (activatedAt == null) activatedAt = now; + return activatedAt; + } + } +} diff --git a/backend/src/test/java/net/modtale/service/jam/ModjamMembershipPersistenceTest.java b/backend/src/test/java/net/modtale/service/jam/ModjamMembershipPersistenceTest.java new file mode 100644 index 000000000..c4e6a6223 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/jam/ModjamMembershipPersistenceTest.java @@ -0,0 +1,138 @@ +package net.modtale.service.jam; + +import java.util.List; +import java.util.Set; +import net.modtale.model.jam.Modjam; +import net.modtale.model.user.User; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ModjamMembershipPersistenceTest { + private MongoTemplate mongo; + private ModjamMembershipPersistence persistence; + + @BeforeEach + void setUp() { + mongo = mock(MongoTemplate.class); + persistence = new ModjamMembershipPersistence(mongo); + } + + @Test + void joinsUseTargetedSetOperationsOnBothSidesAndRepeatRepairsInterruptedWrites() { + when(mongo.findAndModify(any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(Modjam.class))) + .thenReturn(new Modjam()); + persistence.participate("jam", "user", true); + persistence.participate("jam", "user", true); + var change = ArgumentCaptor.forClass(Update.class); + var options = ArgumentCaptor.forClass(FindAndModifyOptions.class); + verify(mongo, times(2)).findAndModify(any(Query.class), change.capture(), options.capture(), eq(Modjam.class)); + assertEquals(new Document("$addToSet", new Document("participantIds", "user")), change.getValue().getUpdateObject()); + assertFalse(options.getValue().isUpsert()); + var accountChange = ArgumentCaptor.forClass(Update.class); + var accountQuery = ArgumentCaptor.forClass(Query.class); + verify(mongo, times(4)).updateFirst(accountQuery.capture(), accountChange.capture(), eq(User.class)); + assertEquals(new Document("$addToSet", new Document("joinedModjamIds", "jam")), accountChange.getValue().getUpdateObject()); + assertEquals(new Document("_id", "user").append("deletedAt", null), accountQuery.getValue().getQueryObject()); + assertEquals(new Document("_id", "user").append("deletedAt", null).append("joinedModjamIds", null), + accountQuery.getAllValues().getFirst().getQueryObject()); + verify(mongo, never()).save(any()); + } + + @Test + void leavesPullOnlyTheRequestedRelationship() { + when(mongo.findAndModify(any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(Modjam.class))) + .thenReturn(new Modjam()); + persistence.participate("jam", "user", false); + var change = ArgumentCaptor.forClass(Update.class); + verify(mongo).findAndModify(any(Query.class), change.capture(), any(FindAndModifyOptions.class), eq(Modjam.class)); + assertEquals(new Document("$pull", new Document("participantIds", "user")), change.getValue().getUpdateObject()); + var accountChange = ArgumentCaptor.forClass(Update.class); + verify(mongo, times(2)).updateFirst(any(Query.class), accountChange.capture(), eq(User.class)); + assertEquals(new Document("$pull", new Document("joinedModjamIds", "jam")), accountChange.getValue().getUpdateObject()); + } + + @Test + void missingJamDoesNotCreateDocumentsOrChangeTheAccount() { + assertNull(persistence.participate("missing", "user", true)); + verify(mongo, never()).updateFirst(any(Query.class), any(Update.class), eq(User.class)); + verify(mongo, never()).save(any()); + verify(mongo, never()).insert(any()); + } + + @Test + void invitationAddsOneIdentityToCurrentMapWithoutReplacingOtherInvitesOrRoles() { + persistence.inviteJudge("jam", "$user.id", "$display"); + var query = ArgumentCaptor.forClass(Query.class); + var update = ArgumentCaptor.forClass(AggregationUpdate.class); + var options = ArgumentCaptor.forClass(FindAndModifyOptions.class); + verify(mongo).findAndModify(query.capture(), update.capture(), options.capture(), eq(Modjam.class)); + assertEquals("jam", query.getValue().getQueryObject().getString("_id")); + assertTrue(query.getValue().getQueryObject().containsKey("$expr")); + assertFalse(options.getValue().isUpsert()); + var pipeline = update.getValue().toPipeline(Aggregation.DEFAULT_CONTEXT); + assertEquals(2, pipeline.size()); + var fields = pipeline.getFirst().get("$set", Document.class); + assertEquals(Set.of("pendingJudgeInviteUsers"), fields.keySet()); + String json = fields.toJson(); + assertTrue(json.contains("$objectToArray")); + assertTrue(json.contains("$arrayToObject")); + assertTrue(json.contains("$literal")); + assertTrue(json.contains("$user.id")); + assertTrue(json.contains("$display")); + var display = pipeline.getLast().get("$set", Document.class); + assertEquals(Set.of("pendingJudgeInvites"), display.keySet()); + assertTrue(display.toJson().contains("$pendingJudgeInviteUsers")); + } + + @Test + void acceptanceRequiresAnIdBoundPendingInviteAndAddsJudgeAsASet() { + persistence.answerJudgeInvite("jam", "user", true); + var query = ArgumentCaptor.forClass(Query.class); + var update = ArgumentCaptor.forClass(AggregationUpdate.class); + verify(mongo).findAndModify(query.capture(), update.capture(), any(FindAndModifyOptions.class), eq(Modjam.class)); + String guard = query.getValue().getQueryObject().get("$expr", Document.class).toJson(); + assertTrue(guard.contains("$in")); + assertTrue(guard.contains("$literal")); + assertTrue(guard.contains("$$invite.k")); + var fields = update.getValue().toPipeline(Aggregation.DEFAULT_CONTEXT).getFirst().get("$set", Document.class); + assertEquals(Set.of("pendingJudgeInviteUsers", "judgeIds"), fields.keySet()); + assertTrue(fields.get("pendingJudgeInviteUsers", Document.class).toJson().contains("$filter")); + assertEquals(new Document("$setUnion", List.of(new Document("$ifNull", List.of("$judgeIds", List.of())), + new Document("$literal", List.of("user")))), fields.get("judgeIds")); + } + + @Test + void declineDoesNotReplaceOrRemoveAcceptedJudges() { + persistence.answerJudgeInvite("jam", "user", false); + var update = ArgumentCaptor.forClass(AggregationUpdate.class); + verify(mongo).findAndModify(any(Query.class), update.capture(), any(FindAndModifyOptions.class), eq(Modjam.class)); + var fields = update.getValue().toPipeline(Aggregation.DEFAULT_CONTEXT).getFirst().get("$set", Document.class); + assertEquals(Set.of("pendingJudgeInviteUsers"), fields.keySet()); + } + + @Test + void removalsFilterOnlySelectedIdsFromCurrentRoster() { + persistence.removeJudges("jam", List.of("user")); + var update = ArgumentCaptor.forClass(AggregationUpdate.class); + verify(mongo).findAndModify(any(Query.class), update.capture(), any(FindAndModifyOptions.class), eq(Modjam.class)); + var fields = update.getValue().toPipeline(Aggregation.DEFAULT_CONTEXT).getFirst().get("$set", Document.class); + assertEquals(Set.of("pendingJudgeInviteUsers", "judgeIds"), fields.keySet()); + assertEquals(new Document("$setDifference", List.of(new Document("$ifNull", List.of("$judgeIds", List.of())), + new Document("$literal", List.of("user")))), fields.get("judgeIds")); + assertFalse(fields.containsKey("participantIds")); + assertFalse(fields.containsKey("organizerMembers")); + verify(mongo, never()).save(any()); + } +} diff --git a/backend/src/test/java/net/modtale/service/jam/ModjamOrganizerServiceTest.java b/backend/src/test/java/net/modtale/service/jam/ModjamOrganizerServiceTest.java new file mode 100644 index 000000000..cf69080f3 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/jam/ModjamOrganizerServiceTest.java @@ -0,0 +1,87 @@ +package net.modtale.service.jam; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import net.modtale.model.jam.Modjam; +import net.modtale.model.jam.Modjam.JamPermission; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.web.server.ResponseStatusException; + +class ModjamOrganizerServiceTest { + private ModjamOrganizerService service; + private ModjamRepository jams; + private UserRepository users; + private MongoTemplate mongo; + private Modjam jam; + + @BeforeEach + void setUp() { + jams = mock(ModjamRepository.class); + users = mock(UserRepository.class); + mongo = mock(MongoTemplate.class); + when(mongo.updateFirst(any(org.springframework.data.mongodb.core.query.Query.class), any(org.springframework.data.mongodb.core.query.Update.class), eq(Modjam.class))) + .thenReturn(com.mongodb.client.result.UpdateResult.acknowledged(1, 1L, null)); + service = new ModjamOrganizerService(jams, users, mongo); + jam = new Modjam(); + jam.setId("jam-1"); + jam.setHostId("host-1"); + jam.setOrganizerRoles(new ArrayList<>(List.of(new Modjam.OrganizerRole("editor", "Editor", "#123456", Set.of(JamPermission.EDIT_DETAILS))))); + jam.setOrganizerMembers(new ArrayList<>(List.of(new Modjam.OrganizerMember("editor-1", "editor")))); + when(jams.findById("jam-1")).thenReturn(Optional.of(jam)); + } + + @Test + void hostHasEveryPermissionAndMembersOnlyHaveGrantedPermissions() { + for (JamPermission permission : JamPermission.values()) assertTrue(service.permits(jam, "host-1", permission)); + assertTrue(service.permits(jam, "editor-1", JamPermission.EDIT_DETAILS)); + assertFalse(service.permits(jam, "editor-1", JamPermission.ANNOUNCE_WINNERS)); + assertFalse(service.permits(jam, null, JamPermission.EDIT_DETAILS)); + assertFalse(service.permits(jam, "stranger", JamPermission.EDIT_DETAILS)); + jam.setOrganizerRoles(List.of()); + assertFalse(service.permits(jam, "editor-1", JamPermission.EDIT_DETAILS)); + } + + @Test + void delegatedEditorsCannotGrantThemselvesExtraPermissions() { + assertThrows(ResponseStatusException.class, () -> service.saveRole("jam-1", "editor-1", + new Modjam.OrganizerRole("editor", "Owner", "#123456", Set.of(JamPermission.ANNOUNCE_WINNERS)))); + verifyNoInteractions(mongo); + } + + @Test + void invitationBindsToUserIdAndAcceptedMembershipIsNotReplacedByUsernameReuse() { + User target = new User(); + target.setId("invited-id"); + target.setUsername("Builder"); + when(users.findByUsernameIgnoreCase("Builder")).thenReturn(Optional.of(target)); + service.invite("jam-1", "host-1", " Builder ", "editor"); + assertEquals("invited-id", jam.getPendingOrganizerInvites().getFirst().userId()); + assertThrows(ResponseStatusException.class, () -> service.answerInvite("jam-1", "new-owner-of-username", true)); + service.answerInvite("jam-1", "invited-id", true); + assertTrue(service.permits(jam, "invited-id", JamPermission.EDIT_DETAILS)); + assertTrue(jam.getPendingOrganizerInvites().isEmpty()); + assertThrows(ResponseStatusException.class, () -> service.answerInvite("jam-1", "invited-id", true)); + } + + @Test + void rolesCannotBeDeletedWhileAssignedAndHostCannotBeRemoved() { + assertThrows(IllegalArgumentException.class, () -> service.deleteRole("jam-1", "host-1", "editor")); + assertThrows(IllegalArgumentException.class, () -> service.remove("jam-1", "host-1", "host-1")); + service.remove("jam-1", "host-1", "editor-1"); + assertFalse(service.permits(jam, "editor-1", JamPermission.EDIT_DETAILS)); + service.deleteRole("jam-1", "host-1", "editor"); + assertTrue(jam.getOrganizerRoles().isEmpty()); + verify(jams, never()).save(any()); + } +} diff --git a/backend/src/test/java/net/modtale/service/jam/ModjamProjectReleasePersistenceTest.java b/backend/src/test/java/net/modtale/service/jam/ModjamProjectReleasePersistenceTest.java new file mode 100644 index 000000000..fc9eaf6df --- /dev/null +++ b/backend/src/test/java/net/modtale/service/jam/ModjamProjectReleasePersistenceTest.java @@ -0,0 +1,79 @@ +package net.modtale.service.jam; + +import java.util.List; +import java.util.Set; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectStatus; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ModjamProjectReleasePersistenceTest { + @Test + void claimRequiresUncanceledPrivateSnapshotAndCurrentSafeVersions() { + MongoTemplate mongo = mock(MongoTemplate.class); + ModjamProjectReleasePersistence persistence = new ModjamProjectReleasePersistence(mongo); + Project snapshot = snapshot(); + Project claimed = new Project(); + when(mongo.findAndModify(any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(Project.class))) + .thenReturn(claimed); + assertSame(claimed, persistence.claimRelease(snapshot)); + var query = ArgumentCaptor.forClass(Query.class); + var change = ArgumentCaptor.forClass(Update.class); + var options = ArgumentCaptor.forClass(FindAndModifyOptions.class); + verify(mongo).findAndModify(query.capture(), change.capture(), options.capture(), eq(Project.class)); + var clauses = query.getValue().getQueryObject().getList("$and", Document.class); + var guard = clauses.getFirst(); + assertEquals("project", guard.getString("_id")); + assertEquals(ProjectStatus.PRIVATE, guard.get("status")); + assertEquals(true, guard.getBoolean("modjamPublicationPending")); + assertTrue(guard.containsKey("deletedAt")); + assertNull(guard.get("deletedAt")); + assertEquals("observed-update", guard.getString("updatedAt")); + assertEquals(List.of("jam"), guard.get("modjamIds")); + assertEquals(ProjectClassification.PLUGIN, guard.get("classification")); + assertTrue(clauses.get(1).toString().contains("SCANNING")); + assertTrue(clauses.get(2).toString().contains("APPROVED")); + var fields = change.getValue().getUpdateObject().get("$set", Document.class); + assertEquals(Set.of("status", "modjamPublicationPending", "updatedAt", "rankingDirty"), fields.keySet()); + assertEquals(ProjectStatus.PUBLISHED, fields.get("status")); + assertEquals(false, fields.getBoolean("modjamPublicationPending")); + assertEquals(true, fields.getBoolean("rankingDirty")); + assertFalse(options.getValue().isUpsert()); + assertTrue(options.getValue().isReturnNew()); + assertEquals(ProjectStatus.PRIVATE, snapshot.getStatus()); + assertTrue(snapshot.isModjamPublicationPending()); + verify(mongo, never()).save(any()); + } + + @Test + void unmatchedCanceledClaimDoesNotMutateSnapshotOrInsertAProject() { + MongoTemplate mongo = mock(MongoTemplate.class); + Project snapshot = snapshot(); + assertNull(new ModjamProjectReleasePersistence(mongo).claimRelease(snapshot)); + assertEquals(ProjectStatus.PRIVATE, snapshot.getStatus()); + assertTrue(snapshot.isModjamPublicationPending()); + verify(mongo, never()).save(any()); + verify(mongo, never()).insert(any()); + } + + private static Project snapshot() { + Project project = new Project(); + project.setId("project"); + project.setStatus(ProjectStatus.PRIVATE); + project.setModjamPublicationPending(true); + project.setUpdatedAt("observed-update"); + project.setClassification(ProjectClassification.PLUGIN); + project.setModjamIds(List.of("jam")); + return project; + } +} diff --git a/backend/src/test/java/net/modtale/service/jam/ModjamVotePersistenceTest.java b/backend/src/test/java/net/modtale/service/jam/ModjamVotePersistenceTest.java new file mode 100644 index 000000000..388b66f6b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/jam/ModjamVotePersistenceTest.java @@ -0,0 +1,119 @@ +package net.modtale.service.jam; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.modtale.model.jam.ModjamSubmission; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ModjamVotePersistenceTest { + private MongoTemplate mongo; + private ModjamVotePersistence persistence; + + @BeforeEach + void setUp() { + mongo = mock(MongoTemplate.class); + persistence = new ModjamVotePersistence(mongo); + } + + @Test + void replacesOnlyMatchingVoterAndCategoryInOneCurrentDocumentPipeline() { + var vote = new ModjamSubmission.Vote("vote-1", "$voter", "$category", 7, true); + persistence.replaceBallot("jam-1", "sub-1", vote); + var query = ArgumentCaptor.forClass(Query.class); + var update = ArgumentCaptor.forClass(AggregationUpdate.class); + var options = ArgumentCaptor.forClass(FindAndModifyOptions.class); + verify(mongo).findAndModify(query.capture(), update.capture(), options.capture(), eq(ModjamSubmission.class)); + assertEquals(new Document("_id", "sub-1").append("jamId", "jam-1"), query.getValue().getQueryObject()); + assertFalse(options.getValue().isUpsert()); + assertTrue(options.getValue().isReturnNew()); + List pipeline = update.getValue().toPipeline(Aggregation.DEFAULT_CONTEXT); + assertEquals(1, pipeline.size()); + var fields = pipeline.getFirst().get("$set", Document.class); + assertEquals(Set.of("votes", "voteRevision"), fields.keySet()); + var concat = fields.get("votes", Document.class).getList("$concatArrays", Document.class); + var filter = concat.getFirst().get("$filter", Document.class); + assertEquals(new Document("$ifNull", List.of("$votes", List.of())), filter.get("input")); + var and = filter.get("cond", Document.class).getList("$not", Document.class).getFirst().getList("$and", Document.class); + assertEquals(new Document("$eq", List.of("$$ballot.voterId", new Document("$literal", "$voter"))), and.getFirst()); + assertEquals(new Document("$eq", List.of("$$ballot.categoryId", new Document("$literal", "$category"))), and.getLast()); + var appended = concat.getLast().getList("$literal", Document.class).getFirst(); + assertEquals("vote-1", appended.getString("_id")); + assertEquals(7, appended.getInteger("score")); + assertEquals(true, appended.getBoolean("isJudge")); + assertEquals(new Document("$add", List.of(new Document("$ifNull", List.of("$voteRevision", 0)), 1)), fields.get("voteRevision")); + } + + @Test + void noMatchNeverFallsBackToInsertOrWholeDocumentReplacement() { + var vote = new ModjamSubmission.Vote("vote-1", "voter", "category", 7, false); + assertNull(persistence.replaceBallot("jam-1", "missing", vote)); + verify(mongo, never()).save(any()); + verify(mongo, never()).insert(any()); + } + + @Test + void scoreWritesAreNarrowAndGuardedByObservedVoteRevision() { + ModjamSubmission snapshot = snapshot(12); + persistence.saveScores(snapshot); + var query = ArgumentCaptor.forClass(Query.class); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(query.capture(), update.capture(), eq(ModjamSubmission.class)); + var clauses = query.getValue().getQueryObject().getList("$and", Document.class); + assertEquals(new Document("_id", "sub-1").append("jamId", "jam-1"), clauses.getFirst()); + assertEquals(new Document("voteRevision", 12L), clauses.getLast()); + var fields = update.getValue().getUpdateObject().get("$set", Document.class); + assertEquals(Set.of("categoryScores", "totalScore", "judgeCategoryScores", "totalJudgeScore", "totalPublicScore", "rank"), fields.keySet()); + assertFalse(fields.containsKey("votes")); + assertFalse(fields.containsKey("awardTitle")); + verify(mongo, never()).save(any()); + } + + @Test + void legacyUnversionedBallotsHaveAZeroOrMissingRevisionGuard() { + persistence.saveScores(snapshot(0)); + var query = ArgumentCaptor.forClass(Query.class); + verify(mongo).updateFirst(query.capture(), any(Update.class), eq(ModjamSubmission.class)); + var revision = query.getValue().getQueryObject().getList("$and", Document.class).getLast(); + assertEquals(List.of(new Document("voteRevision", 0), + new Document("voteRevision", new Document("$exists", false))), revision.get("$or")); + } + + @Test + void finalAwardsCannotOverwriteAnInFlightBallotOrScoreUpdate() { + ModjamSubmission snapshot = snapshot(3); + snapshot.setWinner(true); + snapshot.setAwardTitle("Best Overall"); + persistence.saveAward(snapshot); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(ModjamSubmission.class)); + assertEquals(new Document("$set", new Document("isWinner", true).append("awardTitle", "Best Overall")), + update.getValue().getUpdateObject()); + verify(mongo, never()).save(any()); + } + + private static ModjamSubmission snapshot(long revision) { + ModjamSubmission snapshot = new ModjamSubmission(); + snapshot.setId("sub-1"); + snapshot.setJamId("jam-1"); + snapshot.setVoteRevision(revision); + snapshot.setCategoryScores(Map.of("quality", 7.0)); + snapshot.setTotalScore(7.0); + snapshot.setRank(1); + return snapshot; + } +} diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java index 3caf60050..7b7b3b697 100644 --- a/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java @@ -106,7 +106,9 @@ void setUp() { mock(ScoringService.class), accessControlService, projectAccessService, - securityIssueAnalysisService + securityIssueAnalysisService, + mock(net.modtale.service.jam.ModjamEmbargoService.class), + mock(net.modtale.service.jam.ModjamProjectReleasePersistence.class) ); lifecycleService = new LifecycleService( projectDraftWorkflowService, diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ModjamModerationEmbargoTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ModjamModerationEmbargoTest.java new file mode 100644 index 000000000..6326c0b9f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ModjamModerationEmbargoTest.java @@ -0,0 +1,43 @@ +package net.modtale.service.project.lifecycle; + +import net.modtale.exception.InvalidProjectRequestException; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.user.User; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.audit.AdminAuditLogger; +import net.modtale.service.admin.project.ProjectModerationService; +import net.modtale.service.analytics.ScoringService; +import net.modtale.service.communication.NotificationService; +import net.modtale.service.jam.ModjamEmbargoService; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.ScanService; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ModjamModerationEmbargoTest { + @Test + void moderationCannotExposePrivateSecretEntriesAsUnlisted() { + var projects = mock(ProjectRepository.class); + var projectService = mock(ProjectService.class); + var scoring = mock(ScoringService.class); + var notifications = mock(NotificationService.class); + var audit = mock(AdminAuditLogger.class); + var embargo = mock(ModjamEmbargoService.class); + var service = new ProjectModerationService(projects, projectService, + mock(ProjectRetentionService.class), mock(ProjectDeletionService.class), scoring, + notifications, mock(ScanService.class), mock(ProjectVersionAccessService.class), audit, embargo); + Project project = new Project(); + project.setStatus(ProjectStatus.PRIVATE); + project.setModjamPublicationPending(true); + when(projectService.getRawProjectById("secret")).thenReturn(project); + when(embargo.hasActiveEmbargo(project)).thenReturn(true); + assertThrows(InvalidProjectRequestException.class, () -> service.unlistProject(new User(), "secret", "moderation")); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + assertTrue(project.isModjamPublicationPending()); + verifyNoInteractions(projects, scoring, notifications, audit); + } +} diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ModjamProjectPublicationTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ModjamProjectPublicationTest.java new file mode 100644 index 000000000..9c691d2c9 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ModjamProjectPublicationTest.java @@ -0,0 +1,226 @@ +package net.modtale.service.project.lifecycle; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.modtale.exception.InvalidProjectRequestException; +import net.modtale.model.jam.Modjam; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectStatus; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.user.User; +import net.modtale.repository.jam.ModjamRepository; +import net.modtale.repository.jam.ModjamSubmissionRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.analytics.ScoringService; +import net.modtale.service.analytics.TrackingService; +import net.modtale.service.communication.ProjectNotificationService; +import net.modtale.service.communication.WebhookService; +import net.modtale.service.jam.ModjamEmbargoService; +import net.modtale.service.project.access.ProjectAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.security.issue.SecurityIssueAnalysisService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class ModjamProjectPublicationTest { + private ProjectPublicationService service; + private ProjectRepository projects; + private ProjectService projectService; + private ProjectNotificationService notifications; + private WebhookService webhooks; + private TrackingService tracking; + private ScoringService scoring; + private ProjectAccessService projectAccess; + private ModjamRepository jams; + private Project project; + private Modjam jam; + private User admin; + private net.modtale.service.jam.ModjamProjectReleasePersistence releasePersistence; + + @BeforeEach + void setUp() { + projects = mock(ProjectRepository.class); + projectService = mock(ProjectService.class); + notifications = mock(ProjectNotificationService.class); + webhooks = mock(WebhookService.class); + tracking = mock(TrackingService.class); + scoring = mock(ScoringService.class); + projectAccess = mock(ProjectAccessService.class); + jams = mock(ModjamRepository.class); + AccessControlService access = mock(AccessControlService.class); + var embargo = new ModjamEmbargoService(jams, mock(ModjamSubmissionRepository.class)); + releasePersistence = mock(net.modtale.service.jam.ModjamProjectReleasePersistence.class); + service = new ProjectPublicationService(projects, projectService, notifications, webhooks, + tracking, scoring, access, projectAccess, mock(SecurityIssueAnalysisService.class), embargo, releasePersistence); + jam = new Modjam(); + jam.setId("jam-1"); + jam.setStatus("ACTIVE"); + jam.setHideSubmissions(true); + jam.setStartDate(Instant.now().minusSeconds(3600)); + jam.setEndDate(Instant.now().plusSeconds(3600)); + jam.setVotingEndDate(Instant.now().plusSeconds(7200)); + project = new Project(); + project.setId("project-1"); + project.setStatus(ProjectStatus.PENDING); + project.setClassification(ProjectClassification.PLUGIN); + project.setModjamIds(new ArrayList<>(List.of("jam-1"))); + ProjectVersion version = new ProjectVersion(); + version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + project.setVersions(new ArrayList<>(List.of(version))); + admin = new User(); + admin.setUsername("reviewer"); + when(access.canApproveProjectReviews(admin)).thenReturn(true); + when(access.hasProjectPermission(any(), any(), anyString())).thenReturn(true); + when(jams.findById("jam-1")).thenReturn(Optional.of(jam)); + when(projectAccess.requireProject("project-1")).thenReturn(project); + when(projectAccess.requireProjectPermission(anyString(), any(), anyString(), anyString())).thenReturn(project); + when(projects.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(releasePersistence.claimRelease(any())).thenAnswer(invocation -> { + Project claimed = invocation.getArgument(0); + claimed.setStatus(ProjectStatus.PUBLISHED); + claimed.setModjamPublicationPending(false); + return claimed; + }); + } + + @Test + void approvalKeepsSecretEntryPrivateAndSuppressesPublicSideEffects() { + service.publishProject("project-1", admin); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + assertTrue(project.isModjamPublicationPending()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, project.getVersions().getFirst().getReviewStatus()); + verify(projectService).evictProjectCache(project); + verifyNoInteractions(notifications, webhooks, tracking); + } + + @Test + void staleVotingStatusCannotReleaseEntryBeforeSubmissionDeadline() { + jam.setStatus("VOTING"); + service.publishProject("project-1", admin); + clearInvocations(projects, projectService); + service.releaseModjamEmbargo(project); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + verifyNoInteractions(projects, projectService, notifications, webhooks, tracking); + } + + @Test + void secretEntryPublishesOnceVotingOpensAndInvalidatesCaches() { + service.publishProject("project-1", admin); + clearInvocations(projects, projectService, scoring); + jam.setEndDate(Instant.now().minusSeconds(1)); + service.releaseModjamEmbargo(project); + assertEquals(ProjectStatus.PUBLISHED, project.getStatus()); + assertFalse(project.isModjamPublicationPending()); + verify(releasePersistence).claimRelease(project); + verifyNoInteractions(scoring); + verify(projectService).evictProjectCache(project); + verify(notifications).notifyNewProject(project); + verify(webhooks).triggerWebhook(project); + verify(webhooks).triggerDiscordWebhook(project); + verify(tracking).logNewProject("project-1"); + clearInvocations(projects, projectService, notifications, webhooks, tracking); + service.releaseModjamEmbargo(project); + verifyNoInteractions(projects, projectService, notifications, webhooks, tracking); + } + + @Test + void approvalAfterVotingStartsPublishesNormally() { + jam.setEndDate(Instant.now().minusSeconds(1)); + service.publishProject("project-1", admin); + assertEquals(ProjectStatus.PUBLISHED, project.getStatus()); + assertFalse(project.isModjamPublicationPending()); + verify(notifications).notifyNewProject(project); + } + + @Test + void anotherSecretJamContinuesToHoldPublication() { + service.publishProject("project-1", admin); + jam.setEndDate(Instant.now().minusSeconds(1)); + Modjam other = new Modjam(); + other.setStatus("DRAFT"); + other.setHideSubmissions(true); + project.getModjamIds().add("other-jam"); + when(jams.findById("other-jam")).thenReturn(Optional.of(other)); + service.releaseModjamEmbargo(project); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + verifyNoInteractions(notifications, webhooks, tracking); + } + + @Test + void unrelatedPrivateProjectIsNeverReleased() { + project.setStatus(ProjectStatus.PRIVATE); + project.setModjamPublicationPending(false); + jam.setEndDate(Instant.now().minusSeconds(1)); + service.releaseModjamEmbargo(project); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + verifyNoInteractions(projects, notifications, webhooks, tracking); + } + + @Test + void ownerCannotUnlistOrArchiveSecretEntryDuringEmbargo() { + service.publishProject("project-1", admin); + clearInvocations(projects); + assertThrows(InvalidProjectRequestException.class, () -> service.unlistProject("project-1", admin)); + assertThrows(InvalidProjectRequestException.class, () -> service.archiveProject("project-1", admin)); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + verifyNoInteractions(projects, notifications, webhooks, tracking); + } + + @Test + void explicitPrivateChoiceCancelsAutomaticPublication() { + service.publishProject("project-1", admin); + service.privateProject("project-1", admin); + assertFalse(project.isModjamPublicationPending()); + jam.setEndDate(Instant.now().minusSeconds(1)); + service.releaseModjamEmbargo(project); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + verifyNoInteractions(notifications, webhooks, tracking); + } + + @Test + void staleReleaseSnapshotCannotOverwriteAConcurrentPrivateChoice() { + service.publishProject("project-1", admin); + Project stale = new Project(); + org.springframework.beans.BeanUtils.copyProperties(project, stale); + service.privateProject("project-1", admin); + jam.setEndDate(Instant.now().minusSeconds(1)); + doReturn(null).when(releasePersistence).claimRelease(stale); + clearInvocations(projects, projectService, scoring); + service.releaseModjamEmbargo(stale); + assertEquals(ProjectStatus.PRIVATE, project.getStatus()); + assertFalse(project.isModjamPublicationPending()); + verifyNoInteractions(projects, projectService, scoring, notifications, webhooks, tracking); + } + + @Test + void competingSchedulerClaimsOnlyNotifyOnce() { + service.publishProject("project-1", admin); + Project stale = new Project(); + org.springframework.beans.BeanUtils.copyProperties(project, stale); + Project claimed = new Project(); + org.springframework.beans.BeanUtils.copyProperties(project, claimed); + claimed.setStatus(ProjectStatus.PUBLISHED); + claimed.setModjamPublicationPending(false); + jam.setEndDate(Instant.now().minusSeconds(1)); + doReturn(claimed).doReturn(null).when(releasePersistence).claimRelease(stale); + clearInvocations(projects, projectService, notifications, webhooks, tracking); + service.releaseModjamEmbargo(stale); + service.releaseModjamEmbargo(stale); + verify(releasePersistence, times(2)).claimRelease(stale); + verify(projectService).evictProjectCache(claimed); + verify(notifications).notifyNewProject(claimed); + verify(webhooks).triggerWebhook(claimed); + verify(webhooks).triggerDiscordWebhook(claimed); + verify(tracking).logNewProject("project-1"); + verifyNoInteractions(projects); + } +} diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java index 4f2690e8a..d9e4eb252 100644 --- a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java @@ -25,6 +25,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -42,6 +44,7 @@ class ProjectDeletionServiceTest { private ScoringService scoringService; private StorageService storageService; private MongoTemplate mongoTemplate; + private net.modtale.service.jam.ModjamEmbargoService embargo; @BeforeEach void setUp() { @@ -51,6 +54,7 @@ void setUp() { scoringService = mock(ScoringService.class); storageService = mock(StorageService.class); mongoTemplate = mock(MongoTemplate.class); + embargo = mock(net.modtale.service.jam.ModjamEmbargoService.class); ProjectArtifactDeletionService projectArtifactDeletionService = new ProjectArtifactDeletionService(storageService); service = new ProjectDeletionService( projectRepository, @@ -58,10 +62,30 @@ void setUp() { trackingService, scoringService, projectArtifactDeletionService, - mongoTemplate + mongoTemplate, + embargo ); } + @Test + void cannotRestoreSecretEntryIntoAnyPubliclyReadableState() { + Project project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.DELETED); + when(embargo.hasActiveEmbargo(project)).thenReturn(true); + for (ProjectStatus target : List.of(ProjectStatus.PUBLISHED, ProjectStatus.UNLISTED, ProjectStatus.ARCHIVED)) { + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> service.restore(project, target)); + } + assertEquals(ProjectStatus.DELETED, project.getStatus()); + verify(projectRepository, never()).save(any()); + } + + @Test + void explicitDeleteCancelsAutomaticJamPublication() { + Project project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PRIVATE); + project.setModjamPublicationPending(true); + service.softDelete(project); + assertFalse(project.isModjamPublicationPending()); + } + @Test void softDeleteMarksPublishedProjectsDeletedAndTracksRemoval() { Project project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PUBLISHED); diff --git a/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java b/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java index 52fbbfe1a..983f90bb2 100644 --- a/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java +++ b/backend/src/test/java/net/modtale/service/system/SitemapServiceTest.java @@ -1,17 +1,21 @@ package net.modtale.service.system; +import java.time.Instant; import java.util.List; import java.util.Optional; import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.jam.Modjam; import net.modtale.model.project.Project; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; +import net.modtale.service.ModjamService; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -21,6 +25,7 @@ class SitemapServiceTest { private ProjectRepository projectRepository; private UserRepository userRepository; private ProjectService projectService; + private ModjamService modjamService; private SitemapService service; @BeforeEach @@ -28,12 +33,15 @@ void setUp() { projectRepository = mock(ProjectRepository.class); userRepository = mock(UserRepository.class); projectService = mock(ProjectService.class); + modjamService = mock(ModjamService.class); service = new SitemapService( projectRepository, userRepository, projectService, + modjamService, new AppFrontendProperties("https://modtale.test") ); + when(modjamService.getAllJams()).thenReturn(List.of()); } @Test @@ -77,6 +85,25 @@ void generateSitemapFallsBackToAuthorLookupAndThenAuthorId() { assertTrue(xml.contains("https://modtale.test/creator/missing-id")); } + @Test + void generateSitemapIncludesVisibleJamsAndSkipsDrafts() { + Modjam active = jam("active-jam", "ACTIVE", "host-one", "2026-06-15T12:00:00Z"); + Modjam completed = jam("done-jam", "COMPLETED", "host-one", "2026-06-10T12:00:00Z"); + Modjam draft = jam("draft-jam", "DRAFT", "host-two", "2026-06-20T12:00:00Z"); + + when(projectRepository.findAllForSitemap()).thenReturn(List.of()); + when(modjamService.getAllJams()).thenReturn(List.of(active, completed, draft)); + + String xml = service.generateSitemap(); + + assertTrue(xml.contains("https://modtale.test/jams")); + assertTrue(xml.contains("https://modtale.test/jam/active-jam")); + assertTrue(xml.contains("2026-06-15")); + assertTrue(xml.contains("https://modtale.test/jam/done-jam")); + assertFalse(xml.contains("draft-jam")); + assertEquals(1, countOccurrences(xml, "https://modtale.test/creator/host-one")); + } + private static Project project(String id, String authorId, String author, String updatedAt) { Project project = new Project(); project.setId(id); @@ -86,6 +113,15 @@ private static Project project(String id, String authorId, String author, String return project; } + private static Modjam jam(String slug, String status, String hostName, String updatedAt) { + Modjam jam = new Modjam(); + jam.setSlug(slug); + jam.setStatus(status); + jam.setHostName(hostName); + jam.setUpdatedAt(Instant.parse(updatedAt)); + return jam; + } + private static int countOccurrences(String haystack, String needle) { int count = 0; int index = 0; diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 075fd939a..e576c66af 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.23.3-alpine3.24@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder +FROM node:22.23.4-alpine3.24@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 AS builder WORKDIR /app COPY package*.json ./ COPY scripts ./scripts @@ -14,7 +14,7 @@ ENV PUBLIC_STATUS_URL=$PUBLIC_STATUS_URL RUN npm run check && npm test && npm run build && npm run test:html-cache && npm prune --omit=dev --legacy-peer-deps -FROM node:22.23.3-alpine3.24@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 +FROM node:22.23.4-alpine3.24@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./package.json diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index 749f164d5..5a9869597 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -5,6 +5,14 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ output: 'server', + security: { + allowedDomains: [ + { hostname: 'modtale.net', protocol: 'https' }, + { hostname: '*.modtale.net', protocol: 'https' }, + { hostname: 'localhost', protocol: 'http' }, + { hostname: '127.0.0.1', protocol: 'http' }, + ], + }, adapter: node({ mode: 'middleware', }), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f06cf9fe..85990c6ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,13 +23,13 @@ "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", "@types/react-syntax-highlighter": "^15.5.13", - "astro": "^7.2.10", + "astro": "^7.3.2", "axios": "^1.20.0", "i18next": "^25.10.10", "jsonc-parser": "^3.3.1", "jszip": "^3.10.1", "lucide-react": "^1.39.0", - "mermaid": "^11.17.2", + "mermaid": "^12.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-dropzone": "^20.1.1", @@ -54,15 +54,15 @@ "@types/node": "^26.4.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^4.1.12", "baseline-browser-mapping": "^2.11.20", "jsdom": "^30.0.1", "sharp": "^0.35.4", "typescript": "^6.0.3", - "vitest": "^4.1.11" + "vitest": "^4.1.12" }, "engines": { - "node": ">=22.12.0" + "node": "^22.23.4 || ^24.22.1 || ^26.12.1" } }, "node_modules/@antfu/install-pkg": { @@ -188,29 +188,46 @@ "license": "MIT" }, "node_modules/@astrojs/compiler-binding": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.4.0.tgz", - "integrity": "sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.2.tgz", + "integrity": "sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g==", "license": "MIT", "engines": { "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@astrojs/compiler-binding-darwin-arm64": "0.4.0", - "@astrojs/compiler-binding-darwin-x64": "0.4.0", - "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.0", - "@astrojs/compiler-binding-linux-arm64-musl": "0.4.0", - "@astrojs/compiler-binding-linux-x64-gnu": "0.4.0", - "@astrojs/compiler-binding-linux-x64-musl": "0.4.0", - "@astrojs/compiler-binding-wasm32-wasi": "0.4.0", - "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.0", - "@astrojs/compiler-binding-win32-x64-msvc": "0.4.0" + "@astrojs/compiler-binding-android-arm64": "0.5.2", + "@astrojs/compiler-binding-darwin-arm64": "0.5.2", + "@astrojs/compiler-binding-darwin-x64": "0.5.2", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.2", + "@astrojs/compiler-binding-linux-arm64-musl": "0.5.2", + "@astrojs/compiler-binding-linux-x64-gnu": "0.5.2", + "@astrojs/compiler-binding-linux-x64-musl": "0.5.2", + "@astrojs/compiler-binding-wasm32-wasi": "0.5.2", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.2", + "@astrojs/compiler-binding-win32-x64-msvc": "0.5.2" + } + }, + "node_modules/@astrojs/compiler-binding-android-arm64": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.5.2.tgz", + "integrity": "sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@astrojs/compiler-binding-darwin-arm64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.4.0.tgz", - "integrity": "sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.5.2.tgz", + "integrity": "sha512-1ZG5Ar3dD8HtodX9XH1mRmE0qBXZlvjYXSFfpgFwocqfWnvhFuqtGUNseL8wgfImiRMztIjpQqlogQFQQtgiag==", "cpu": [ "arm64" ], @@ -224,9 +241,9 @@ } }, "node_modules/@astrojs/compiler-binding-darwin-x64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.4.0.tgz", - "integrity": "sha512-FI6G8AY8u6fR1SI/QRR5yGMwtvZwP34CDmZpZ5HwJGa50UM1VISTLhqkhV4a476pmgd25X1Aur2dqw6hUnrlKA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.5.2.tgz", + "integrity": "sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w==", "cpu": [ "x64" ], @@ -240,15 +257,12 @@ } }, "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.4.0.tgz", - "integrity": "sha512-lB9gLFJK7m82EnjaU8nlRBEfcwGNeHidW3sSjODTUjMNaoewVuUz9fwwdY5M4jiSXIqWLH3yl6TX8FTDKA74Sw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.5.2.tgz", + "integrity": "sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -259,15 +273,12 @@ } }, "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.4.0.tgz", - "integrity": "sha512-HPbvWqbxFxyaoQJhLxCaSjtYBx9KBo7JGVzEFZCmMl968a2PsSH0UfiODYgYPXofTOIsIH2aoCcrHXML0IA3ig==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.5.2.tgz", + "integrity": "sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -278,15 +289,12 @@ } }, "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.4.0.tgz", - "integrity": "sha512-tQKolMxoJ/+0AmLWm1PmJ/i+z3i10ZU1bNuVjEDulCf48azEMtUNjTZgHJ5MPtpYRNc7dlETr8QujUfduzoC7Q==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.5.2.tgz", + "integrity": "sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -297,15 +305,12 @@ } }, "node_modules/@astrojs/compiler-binding-linux-x64-musl": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.4.0.tgz", - "integrity": "sha512-5v5YymudsxMHp3NBLCS8BUlu5CRqeLtWD9cKS/4nIhIEHCbpz9okmVV6I0HWqmBAPhWYcDa3vw/vltYPrOQCTA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.5.2.tgz", + "integrity": "sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -316,25 +321,27 @@ } }, "node_modules/@astrojs/compiler-binding-wasm32-wasi": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.4.0.tgz", - "integrity": "sha512-m/phuH3x3PREvv1OnkM44NoPh4MatUadix1fB1u5SvMLCyDTUZykDJbKnWf1cjnYmHdlB8HcjTjl6JrCqAIXcw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.5.2.tgz", + "integrity": "sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.2.2" + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", + "@napi-rs/wasm-runtime": "^1.2.5" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.4.0.tgz", - "integrity": "sha512-B9zYf3okEY83kM8gydlpH2BHP00w4ifxPqlYlWrgTwuD6wnkrJDCwBlgy1q31cERjCJRXN1lrE2VmkLvFjv/6g==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.5.2.tgz", + "integrity": "sha512-Tr13bh+ahTWsjXjzctK7pFpWV+dUU4FtBfETlR7gO+ueex0F+RDch7eRr0YfKysaRhk9tWm14lsCJ5uWyFPNHQ==", "cpu": [ "arm64" ], @@ -348,9 +355,9 @@ } }, "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.4.0.tgz", - "integrity": "sha512-zB0Nrv0dGc0zZWPGDRmmETTPhDRqyZjAjk+gWMlVrJX5U89obpB3VUUE1ZiHxOCN5LQojeLK6O8L/dnoHolvNQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.5.2.tgz", + "integrity": "sha512-SfKkI+jOQYLh6wojPvVPV0lFfQnxisweN9B+qCzRvZp7DHAuGif8Y0WcC5PUgN4jb8VKy57LggQDtggR46Du1g==", "cpu": [ "x64" ], @@ -364,12 +371,12 @@ } }, "node_modules/@astrojs/compiler-rs": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.4.0.tgz", - "integrity": "sha512-koVikeon1kreEy+/JzLQRy3vzHHQVOjycs4degg4vFufKApZOwMZvSSAEztYNhmcQVfNVsVZZI4cEge3cexAbQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.5.2.tgz", + "integrity": "sha512-IvwYLB8t1M86rKO4oPdBi9GP4gzXcgrVlNgocjwos1jeeUuEIV8d9Crn1PvYD/5IKteWOxpIHomf7e8iYlkE+Q==", "license": "MIT", "dependencies": { - "@astrojs/compiler-binding": "0.4.0" + "@astrojs/compiler-binding": "0.5.2" }, "engines": { "node": ">=22.12.0" @@ -434,15 +441,31 @@ } }, "node_modules/@astrojs/markdown-satteri": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.0.tgz", - "integrity": "sha512-wykOOW9KsUVcZweOpY/CeXpdKcCKZy6fQbdcteWFuI75+sQCiqxYM7VKsGa5b+aGl3cYQscFY37rsbbyal5MRw==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.5.tgz", + "integrity": "sha512-0IeGdoFbv0f+442KJhYS44Wg8F3MuHmRsxIEYuFyqkjRubc6dPBFZ7azor5t4AeXLQoT5A5h9HyViKpd70Dl9w==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/prism": "4.0.2", + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/prism": "4.1.1", "github-slugger": "^2.0.0", - "satteri": "^0.10.3" + "satteri": "^0.10.5" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@astrojs/internal-helpers": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.13.0.tgz", + "integrity": "sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "picomatch": "^4.0.7", + "retext-smartypants": "^6.2.0", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "unified": "^11.0.5", + "yaml": "^2.9.1" } }, "node_modules/@astrojs/node": { @@ -460,15 +483,15 @@ } }, "node_modules/@astrojs/prism": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", - "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.1.tgz", + "integrity": "sha512-sXlAXJX6t4QfuLg+CuCDzpg7NoxWcyb4paFprgtFppLeisCBmh935h+tJ5CWXfsXEoW2IDy/ihbRMtiu28Chjw==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.23.4" } }, "node_modules/@astrojs/react": { @@ -494,18 +517,18 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", - "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.1.tgz", + "integrity": "sha512-SMkvMsGOpM0FMAoT5pzhgI3LZC0JSPWf7Evwop+fZ3N6UkusPM7lI+sOJjMNI5OJrVoiei1/AGo1cY7sikTc5A==", "license": "MIT", "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", - "package-manager-detector": "^1.6.0" + "package-manager-detector": "^1.9.0" }, "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0" + "node": ">=22.23.4" } }, "node_modules/@astrojs/yaml2ts": { @@ -852,9 +875,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -868,9 +888,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -884,9 +901,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -900,9 +914,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -927,6 +938,37 @@ "node": ">=14.0.0" } }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@bruits/satteri-win32-arm64-msvc": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", @@ -965,16 +1007,47 @@ "node": ">=18" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", + "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "13.2.0", + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", + "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", + "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", + "license": "Apache-2.0" + }, "node_modules/@chevrotain/types": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", - "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", + "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", "license": "Apache-2.0" }, "node_modules/@clack/core": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.2.tgz", - "integrity": "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", "license": "MIT", "dependencies": { "fast-wrap-ansi": "^0.2.0", @@ -985,12 +1058,12 @@ } }, "node_modules/@clack/prompts": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.6.0.tgz", - "integrity": "sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", "license": "MIT", "dependencies": { - "@clack/core": "1.4.2", + "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -1202,450 +1275,34 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "license": "MIT", "optional": true, "dependencies": { + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@exodus/bytes": { @@ -1731,12 +1388,13 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1749,16 +1407,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1771,20 +1430,21 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1794,12 +1454,13 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1810,12 +1471,13 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1826,15 +1488,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1845,15 +1505,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1864,15 +1522,13 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1883,15 +1539,13 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1902,15 +1556,13 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1921,15 +1573,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1940,15 +1590,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1959,15 +1607,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1978,15 +1624,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1999,19 +1643,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2024,19 +1666,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2049,19 +1689,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2074,19 +1712,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2099,19 +1735,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2124,19 +1758,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2149,19 +1781,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2174,13 +1804,14 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2193,27 +1824,18 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2223,12 +1845,13 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2242,12 +1865,13 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2261,12 +1885,13 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2309,9 +1934,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2325,18 +1950,21 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", - "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==", "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.2" + "@chevrotain/types": "~13.2.0" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", - "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", "license": "MIT", "optional": true, "dependencies": { @@ -2361,18 +1989,34 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -2386,9 +2030,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -2402,9 +2046,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -2418,9 +2062,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -2434,9 +2078,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -2450,9 +2094,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -2466,9 +2110,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -2482,9 +2126,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -2498,9 +2142,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -2514,9 +2158,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -2530,9 +2174,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -2546,9 +2190,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -2561,28 +2205,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -2596,9 +2222,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -2724,13 +2350,13 @@ } }, "node_modules/@shikijs/core": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", - "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz", + "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.4.3", - "@shikijs/types": "4.4.3", + "@shikijs/primitive": "4.5.0", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" @@ -2740,12 +2366,12 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", - "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz", + "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.3", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" }, @@ -2754,12 +2380,12 @@ } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", - "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz", + "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.3", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -2767,24 +2393,24 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", - "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz", + "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.3" + "@shikijs/types": "4.5.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", - "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", + "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.3", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" }, @@ -2793,21 +2419,21 @@ } }, "node_modules/@shikijs/themes": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", - "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz", + "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.3" + "@shikijs/types": "4.5.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", - "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz", + "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -2818,9 +2444,9 @@ } }, "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.1.0.tgz", + "integrity": "sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==", "license": "MIT" }, "node_modules/@standard-schema/spec": { @@ -2976,9 +2602,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2996,9 +2619,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3016,9 +2636,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3036,9 +2653,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3125,20 +2739,6 @@ "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, - "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@tailwindcss/vite": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", @@ -4163,14 +3763,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz", + "integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4184,8 +3784,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "4.1.12", + "vitest": "4.1.12" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4194,16 +3794,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4212,13 +3812,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4239,9 +3839,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -4252,13 +3852,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -4266,14 +3866,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4282,9 +3882,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -4292,13 +3892,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4478,9 +4078,9 @@ } }, "node_modules/am-i-vibing": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", - "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz", + "integrity": "sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA==", "license": "MIT", "dependencies": { "process-ancestry": "^0.1.0" @@ -4585,70 +4185,69 @@ "license": "MIT" }, "node_modules/astro": { - "version": "7.2.10", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.10.tgz", - "integrity": "sha512-uPtD+nK6kQXugyq4kiMPwj9OI3Sae6HSerA5X+fuv2CPaDwxmokFPPFlGRKIAXH0QAKu+zot2q6yrLG61wFmqg==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.2.tgz", + "integrity": "sha512-FAH4cdDlFaVnQGjazLTEIC5vJqGlo5XANc1wIbzQHIqAjJTll/RWTUs+SPNW1JdezG4YGxMZllaE5A41T2kzhA==", "license": "MIT", "dependencies": { - "@astrojs/compiler-rs": "^0.4.0", - "@astrojs/internal-helpers": "0.11.0", - "@astrojs/markdown-satteri": "0.4.0", - "@astrojs/telemetry": "3.3.3", - "@capsizecss/unpack": "^4.0.0", - "@clack/prompts": "^1.1.0", + "@astrojs/compiler-rs": "^0.5.1", + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/markdown-satteri": "0.4.5", + "@astrojs/telemetry": "3.4.1", + "@capsizecss/unpack": "^4.0.1", + "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", - "am-i-vibing": "^0.4.0", + "am-i-vibing": "^0.5.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", - "devalue": "^5.8.1", + "devalue": "^6.0.2", "diff": "^9.0.0", "dset": "^3.1.4", - "es-module-lexer": "^2.0.0", - "esbuild": "^0.28.0", - "find-proc": "0.1.0", + "es-module-lexer": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.1", - "get-tsconfig": "5.0.0-beta.4", + "get-tsconfig": "5.0.0-beta.6", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", - "http-cache-semantics": "^4.2.0", - "js-yaml": "^4.3.0", + "http-cache-semantics": "^4.3.0", "jsonc-parser": "^3.3.1", - "magic-string": "^1.0.0", - "magicast": "^0.5.2", + "magic-string": "^1.4.3", + "magicast": "^0.5.5", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", - "obug": "^2.1.1", - "p-limit": "^7.3.0", - "p-queue": "^9.1.0", - "package-manager-detector": "^1.6.0", + "obug": "^3.0.0", + "p-limit": "^7.3.3", + "p-queue": "^9.3.5", + "package-manager-detector": "^1.9.0", "piccolore": "^0.1.3", - "picomatch": "^4.0.4", - "semver": "^7.7.4", - "shiki": "^4.0.2", - "smol-toml": "^1.6.0", - "svgo": "^4.0.1", - "tinyclip": "^0.1.12", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15", - "ultrahtml": "^1.6.0", - "unifont": "~0.7.5", + "picomatch": "^4.0.7", + "rolldown": "^1.2.13", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "svgo": "^4.1.0", + "tinyclip": "^1.0.3", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "tinyps": "^0.5.0", + "ultrahtml": "^1.7.0", + "unifont": "^1.0.3", "unstorage": "^1.17.5", - "vite": "^8.0.13", - "vitefu": "^1.1.2", + "verkit": "^0.5.0", + "vite": "^8.3.4", + "vitefu": "^1.1.3", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", - "zod": "^4.3.6" + "zod": "^4.6.6" }, "bin": { "astro": "bin/astro.mjs" }, "engines": { - "node": ">=22.12.0", + "node": ">=22.23.4", "npm": ">=9.6.5", "pnpm": ">=7.1.0" }, @@ -4668,25 +4267,57 @@ } } }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.13.0.tgz", + "integrity": "sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "picomatch": "^4.0.7", + "retext-smartypants": "^6.2.0", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "unified": "^11.0.5", + "yaml": "^2.9.1" + } + }, + "node_modules/astro/node_modules/devalue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-6.0.3.tgz", + "integrity": "sha512-3woMEITftG1wKeO/4d/QXH1DVKctTSZg3jNGPpjp9hNr5Hos8MCFF5McTML5ZtgKDObm2R591B0spRp2IVernA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/es-module-lexer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", + "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", + "license": "MIT" + }, "node_modules/astro/node_modules/magic-string": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", - "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "node_modules/astro/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/astro/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.20.0" } }, "node_modules/asynckit": { @@ -4840,9 +4471,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -4889,6 +4520,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chevrotain": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", + "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "13.2.0", + "@chevrotain/gast": "13.2.0", + "@chevrotain/regexp-to-ast": "13.2.0", + "@chevrotain/types": "13.2.0", + "@chevrotain/utils": "13.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -5772,9 +5419,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "license": "MIT" }, "node_modules/devlop": { @@ -5853,9 +5500,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5910,6 +5557,12 @@ "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emmet": { "version": "2.4.11", "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", @@ -5991,6 +5644,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -6030,47 +5684,6 @@ "benchmarks" ] }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6181,9 +5794,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -6206,15 +5819,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fastdom": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", - "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", - "license": "MIT", - "dependencies": { - "strictdom": "^1.0.1" - } - }, "node_modules/fault": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", @@ -6231,19 +5835,10 @@ "node_modules/file-selector": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-5.0.1.tgz", - "integrity": "sha512-v0g/PTeuQgvKCBrVRsfVudvwXlRHSWHEQkVgKawgCGHkEpKA1clp3Om5jvEVhz8G9W/mOYjJH9FhkH4C888PgQ==", - "license": "MIT", - "engines": { - "node": ">= 22" - } - }, - "node_modules/find-proc": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.1.0.tgz", - "integrity": "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==", + "integrity": "sha512-v0g/PTeuQgvKCBrVRsfVudvwXlRHSWHEQkVgKawgCGHkEpKA1clp3Om5jvEVhz8G9W/mOYjJH9FhkH4C888PgQ==", "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 22" } }, "node_modules/flattie": { @@ -6255,10 +5850,16 @@ "node": ">=8" } }, + "node_modules/fnv1a-64": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/fnv1a-64/-/fnv1a-64-0.1.2.tgz", + "integrity": "sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==", + "license": "MIT" + }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -6422,9 +6023,9 @@ } }, "node_modules/get-tsconfig": { - "version": "5.0.0-beta.4", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", - "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -6779,9 +6380,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause" }, "node_modules/http-errors": { @@ -7215,28 +6816,28 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.10.tgz", + "integrity": "sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], "license": "MIT", "dependencies": { - "commander": "^8.3.0" + "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=22.12.0" } }, "node_modules/khroma": { @@ -7273,6 +6874,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7305,6 +6907,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7325,6 +6928,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7345,6 +6949,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7365,6 +6970,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7385,6 +6991,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7405,6 +7012,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7425,6 +7033,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7445,6 +7054,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7465,6 +7075,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7485,6 +7096,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7505,6 +7117,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7595,13 +7208,13 @@ } }, "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -7942,16 +7555,17 @@ "license": "CC0-1.0" }, "node_modules/mermaid": { - "version": "11.17.2", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", - "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.2.0.tgz", + "integrity": "sha512-w7nr/SFrLO7BaVTs3dQTvYSV6nYSBj0Yy9DjBhsIJtAcHqRzXSq4Pg32ujHZ/AdWsAMNEvvhbm5dt9E2tOiHzw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.2.1", + "@mermaid-js/parser": "^2.0.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", + "chevrotain": "~13.2.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", @@ -7959,16 +7573,19 @@ "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", - "dompurify": "^3.3.3", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", "es-toolkit": "^1.45.1", - "fastdom": "1.0.12", - "katex": "^0.16.47", + "katex": "^0.18.0", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/micromark": { @@ -8600,9 +8217,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -8691,6 +8308,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -8711,12 +8329,6 @@ "ufo": "^1.6.1" } }, - "node_modules/ohash": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", - "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "license": "MIT" - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8753,9 +8365,9 @@ "license": "MIT" }, "node_modules/p-limit": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", - "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", + "integrity": "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==", "license": "MIT", "dependencies": { "yocto-queue": "^1.2.1" @@ -8768,9 +8380,9 @@ } }, "node_modules/p-queue": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", - "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.5.tgz", + "integrity": "sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.4", @@ -8784,9 +8396,9 @@ } }, "node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", "license": "MIT", "engines": { "node": ">=20" @@ -8796,9 +8408,9 @@ } }, "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "license": "MIT" }, "node_modules/pako": { @@ -8876,9 +8488,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -8915,9 +8527,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -8934,14 +8546,28 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz", + "integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prettier": { "version": "3.8.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.5.tgz", @@ -9590,12 +9216,12 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", - "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.137.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -9605,21 +9231,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.3", - "@rolldown/binding-darwin-arm64": "1.1.3", - "@rolldown/binding-darwin-x64": "1.1.3", - "@rolldown/binding-freebsd-x64": "1.1.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", - "@rolldown/binding-linux-arm64-gnu": "1.1.3", - "@rolldown/binding-linux-arm64-musl": "1.1.3", - "@rolldown/binding-linux-ppc64-gnu": "1.1.3", - "@rolldown/binding-linux-s390x-gnu": "1.1.3", - "@rolldown/binding-linux-x64-gnu": "1.1.3", - "@rolldown/binding-linux-x64-musl": "1.1.3", - "@rolldown/binding-openharmony-arm64": "1.1.3", - "@rolldown/binding-wasm32-wasi": "1.1.3", - "@rolldown/binding-win32-arm64-msvc": "1.1.3", - "@rolldown/binding-win32-x64-msvc": "1.1.3" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { @@ -9806,9 +9432,9 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -9823,31 +9449,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -9869,17 +9495,17 @@ } }, "node_modules/shiki": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", - "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz", + "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.4.3", - "@shikijs/engine-javascript": "4.4.3", - "@shikijs/engine-oniguruma": "4.4.3", - "@shikijs/langs": "4.4.3", - "@shikijs/themes": "4.4.3", - "@shikijs/types": "4.4.3", + "@shikijs/core": "4.5.0", + "@shikijs/engine-javascript": "4.5.0", + "@shikijs/engine-oniguruma": "4.5.0", + "@shikijs/langs": "4.5.0", + "@shikijs/themes": "4.5.0", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" }, @@ -9901,9 +9527,9 @@ "license": "MIT" }, "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -9913,9 +9539,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9948,16 +9574,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/strictdom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", - "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "license": "MIT" }, "node_modules/string_decoder": { @@ -10119,18 +9738,18 @@ "license": "MIT" }, "node_modules/tinyclip": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", - "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-1.0.3.tgz", + "integrity": "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==", "license": "MIT", "engines": { "node": "^16.14.0 || >= 17.3.0" } }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "license": "MIT", "engines": { "node": ">=18" @@ -10169,6 +9788,12 @@ } } }, + "node_modules/tinyps": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/tinyps/-/tinyps-0.5.1.tgz", + "integrity": "sha512-B6Ykif5HflC3AdDkS4Zst/2muYVfeXoVMy+KorqH80s/1x4vunIORpNySH681X7fjj+dFM9MDKD6/Hsccjb87Q==", + "license": "MIT" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -10321,9 +9946,9 @@ "license": "MIT" }, "node_modules/ultrahtml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", - "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", "license": "MIT" }, "node_modules/uncrypto": { @@ -10333,9 +9958,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz", - "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==", + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -10368,13 +9993,14 @@ } }, "node_modules/unifont": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", - "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-1.0.3.tgz", + "integrity": "sha512-XGnEwj5sWdCbiBX01mWLSBncS0yMRjnLygau3IfdOlfjMacNIQvTGW4VH7pWBq+gGWArfSef/CensMLjMmIr/A==", "license": "MIT", "dependencies": { "css-tree": "^3.1.0", - "ohash": "^2.0.11", + "fnv1a-64": "^0.1.2", + "std-env": "^4.2.0", "undici": "^8.0.0" } }, @@ -10609,6 +10235,18 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -10652,15 +10290,15 @@ } }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -10677,7 +10315,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -10728,6 +10366,255 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -10748,19 +10635,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10788,12 +10675,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -11365,9 +11252,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/package.json b/frontend/package.json index 3d129a871..8d55e614a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "type": "module", "engines": { - "node": ">=22.12.0" + "node": "^22.23.4 || ^24.22.1 || ^26.12.1" }, "scripts": { "dev": "astro dev", @@ -32,13 +32,13 @@ "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", "@types/react-syntax-highlighter": "^15.5.13", - "astro": "^7.2.10", + "astro": "^7.3.2", "axios": "^1.20.0", "i18next": "^25.10.10", "jsonc-parser": "^3.3.1", "jszip": "^3.10.1", "lucide-react": "^1.39.0", - "mermaid": "^11.17.2", + "mermaid": "^12.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-dropzone": "^20.1.1", @@ -63,14 +63,18 @@ "@types/node": "^26.4.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^4.1.12", "baseline-browser-mapping": "^2.11.20", "jsdom": "^30.0.1", "sharp": "^0.35.4", "typescript": "^6.0.3", - "vitest": "^4.1.11" + "vitest": "^4.1.12" }, "overrides": { - "volar-service-yaml": "0.0.71" + "volar-service-yaml": "0.0.71", + "fast-uri": "^3.1.8", + "@tailwindcss/typography": { + "postcss-selector-parser": "^7.1.6" + } } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8b59ea68..3344df5c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,8 @@ const StatusModal = lazy(() => import('@/components/ui/StatusModal').then((modul const Onboarding = lazy(() => import('@/modules/user/components/Onboarding').then((module) => ({ default: module.Onboarding }))); const TermsOfService = lazy(() => import('@/modules/core/views/TermsOfService').then((module) => ({ default: module.TermsOfService }))); const PrivacyPolicy = lazy(() => import('@/modules/core/views/PrivacyPolicy').then((module) => ({ default: module.PrivacyPolicy }))); +const JamsList = lazy(() => import('@/modules/jam/views/JamsList').then((module) => ({ default: module.JamsList }))); +const JamDetail = lazy(() => import('@/modules/jam/views/JamDetail').then((module) => ({ default: module.JamDetail }))); const UserProfile = lazy(() => import('@/modules/user/views/UserProfile').then((module) => ({ default: module.UserProfile }))); const Dashboard = lazy(() => import('@/modules/user/views/Dashboard').then((module) => ({ default: module.Dashboard }))); const VerifyEmail = lazy(() => import('@/modules/auth/views/VerifyEmail').then((module) => ({ default: module.VerifyEmail }))); @@ -78,8 +80,12 @@ const ScrollToTop = () => { useEffect(() => { const previousPathname = previousPathnameRef.current; previousPathnameRef.current = pathname; - - if (previousPathname && SiteRoutes.isSameProjectModalContext(previousPathname, pathname)) { + const jamTabPattern = /^\/jam\/[^/]+\/(overview|rules|entries)$/; + const isSameJamTabTransition = Boolean( + previousPathname && jamTabPattern.test(previousPathname) && jamTabPattern.test(pathname) + && previousPathname.replace(/\/(overview|rules|entries)$/, '') === pathname.replace(/\/(overview|rules|entries)$/, '') + ); + if (isSameJamTabTransition || (previousPathname && SiteRoutes.isSameProjectModalContext(previousPathname, pathname))) { return; } @@ -303,6 +309,9 @@ const AppContent: React.FC = () => { + } /> + } /> + } /> : diff --git a/frontend/src/data/seo-constants.ts b/frontend/src/data/seo-constants.ts index 6c1c264c3..70fe30c47 100644 --- a/frontend/src/data/seo-constants.ts +++ b/frontend/src/data/seo-constants.ts @@ -34,6 +34,7 @@ export const BROWSE_ROUTE_PATHS = new Set([ '/worlds', '/art', '/data', + '/jams', ]); export const UTILITY_NOINDEX_EXACT_PATHS = new Set([ @@ -162,6 +163,53 @@ export const ROUTE_SEO: Record = { }, ], }, + '/jams': { + title: 'Hytale Mod Jams and Creator Events | Modtale', + h1: 'Hytale Mod Jams', + description: 'Discover active and upcoming Hytale mod jams, creator challenges, community events, entries, judging, and results on Modtale.', + keywords: 'hytale mod jams, hytale game jam, hytale creator events, modtale jams, hytale modding competition', + intro: 'Join Hytale creator challenges, follow active competitions, and discover community-built entries from Modtale-hosted mod jams.', + contentBlocks: [ + { + title: 'Build Together in Hytale Mod Jams', + body: 'Modtale jams give creators a focused theme, timeline, rules, and submission flow for building and sharing Hytale projects with the community.', + }, + { + title: 'Follow Entries, Voting, and Results', + body: 'Each jam keeps its participating projects, judging criteria, voting period, and final winners together in one public event page.', + }, + { + title: 'Host Community Creator Events', + body: 'Jam hosts can configure event milestones, invite judges, publish rules, and showcase completed entries without assembling separate tools.', + }, + ], + relatedLinks: [ + { + href: '/mods', + label: 'Browse Hytale Mods', + description: 'Discover projects created across the Modtale community.', + }, + { + href: '/upload', + label: 'Publish a Project', + description: 'Create a Modtale project that can be submitted to eligible jams.', + }, + ], + faq: [ + { + question: 'What is a Hytale mod jam?', + answer: 'A Hytale mod jam is a timed creator event where participants build projects around shared rules or a theme, then submit entries for judging or community voting.', + }, + { + question: 'Can I host a mod jam on Modtale?', + answer: 'Signed-in Modtale users can create a jam, configure its timeline and rules, invite judges, and manage submissions and results.', + }, + { + question: 'Where can I see completed jam entries?', + answer: 'Completed jam pages retain their submitted projects, scores when published, award titles, and winners for later discovery.', + }, + ], + }, '/mods': { title: 'Hytale Mods | Browse Hytale Mods and Modpacks | Modtale', h1: 'Hytale Mods', diff --git a/frontend/src/modules/core/components/Navbar.tsx b/frontend/src/modules/core/components/Navbar.tsx index b34cff7f8..dc996b242 100644 --- a/frontend/src/modules/core/components/Navbar.tsx +++ b/frontend/src/modules/core/components/Navbar.tsx @@ -1,5 +1,5 @@ import React, { lazy, Suspense, useState, useRef, useEffect } from 'react'; -import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, ChevronDown, LayoutGrid, MonitorDown } from 'lucide-react'; +import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, ChevronDown, LayoutGrid, MonitorDown, Trophy } from 'lucide-react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { AnimatedThemeToggler } from '@/components/ui/AnimatedThemeToggler'; import { useMobile } from '@/context/MobileContext'; @@ -20,7 +20,7 @@ interface NavbarProps { onNavigate: (page: string) => void; isDarkMode: boolean; toggleDarkMode: () => void; - onUserClick: (username: string) => void; + onUserClick: (userId: string, username?: string) => void; } export const Navbar: React.FC = ({ @@ -112,6 +112,8 @@ export const Navbar: React.FC = ({ navigate(redirectTo || SiteRoutes.home(), { replace: true }); }; + const isJamPage = currentPage === 'jams' || currentPage.startsWith('jam/'); + return (