diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index af4d46bb1..f305aceed 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: @@ -105,7 +105,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 +130,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 +155,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 +511,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 +857,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 +953,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 +961,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 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/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/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 (