diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c1c2fc5..720ea3f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,8 +31,6 @@ env: STATIC_IP_NAME: "main-ingress-ip" STATIC_IP_ADDRESS: ${{ secrets.STATIC_IP_ADDRESS }} - GH_API_TOKENS: ${{ secrets.GH_API_TOKENS }} - # LinkedIn LINKEDIN_CLIENT_ID: ${{ secrets.LINKEDIN_CLIENT_ID }} LINKEDIN_CLIENT_SECRET: ${{ secrets.LINKEDIN_CLIENT_SECRET }} @@ -149,7 +147,7 @@ jobs: - name: Checkout code ๐Ÿ“ฆ uses: actions/checkout@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - name: Set up Google Cloud CLI ๐Ÿ› ๏ธ uses: google-github-actions/auth@v2 @@ -299,28 +297,13 @@ jobs: # Create application secrets kubectl create secret generic app-secrets \ --namespace=${{ env.NAMESPACE }} \ - --from-literal=github.tokens="${{ env.GH_API_TOKENS }}" \ - --from-literal=linkedin.client.id="${{ env.LINKEDIN_CLIENT_ID }}" \ - --from-literal=linkedin.client.secret="${{ env.LINKEDIN_CLIENT_SECRET }}" \ + --from-literal=github.tokens="${{ secrets.GH_API_TOKENS }}" \ + --from-literal=linkedin.client.id="${{ secrets.LINKEDIN_CLIENT_ID }}" \ + --from-literal=linkedin.client.secret="${{ secrets.LINKEDIN_CLIENT_SECRET }}" \ --dry-run=client -o yaml | kubectl apply -f - echo "โœ… Kubernetes secrets created successfully" - - name: Cleanup GKE Resources ๐Ÿงน - run: | - echo "๐Ÿงน Cleaning up all existing resources in namespace ${{ env.NAMESPACE }}..." - - # Clean up all resources in namespace (except secrets and configmaps we'll recreate) - kubectl delete all --all -n ${{ env.NAMESPACE }} --ignore-not-found=true || echo "No standard resources to clean up" - kubectl delete ingress --all -n ${{ env.NAMESPACE }} --ignore-not-found=true || echo "No ingress resources to clean up" - kubectl delete backendconfig --all -n ${{ env.NAMESPACE }} --ignore-not-found=true || echo "No backendconfig resources to clean up" - - # Wait for NEG cleanup to complete (they take time to detach from load balancers) - echo "โณ Waiting for NEG cleanup to complete..." - sleep 30 - - echo "โœ… Cleanup completed successfully" - - name: Deploy to GKE ๐Ÿš€ env: REGISTRY_URL: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAME }} @@ -337,11 +320,11 @@ jobs: run: | echo "๐Ÿš€ Processing and applying Kubernetes configurations..." - # Always use 'latest' tag for active K8s deployments for simplicity - BACKEND_VERSION="latest" - CACHE_UPDATER_VERSION="latest" - FRONTEND_VERSION="latest" - echo "๐Ÿท๏ธ Using 'latest' tags for all K8s deployments" + # Use the versioned tag built in this run for all K8s deployments + BACKEND_VERSION="${{ env.VERSION }}" + CACHE_UPDATER_VERSION="${{ env.VERSION }}" + FRONTEND_VERSION="${{ env.VERSION }}" + echo "๐Ÿท๏ธ Using versioned tags for all K8s deployments: ${{ env.VERSION }}" # Create temporary directory mkdir -p processed_k8s @@ -393,7 +376,7 @@ jobs: kubectl apply --server-side=true --force-conflicts -n ${{ env.NAMESPACE }} -f processed_k8s/frontend.yaml echo "โœ… Frontend applied successfully" - echo "โœ… All resources cleanup completed" + echo "โœ… All resources applied" # Check deployment status immediately after creation echo "๐Ÿ” Immediate deployment status check..." @@ -673,5 +656,3 @@ jobs: delete-ghost-images: true delete-tags: "*" exclude-tags: latest,${{ env.VERSION }} - - diff --git a/backend/pom.xml b/backend/pom.xml index 8639bab..28a2571 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -19,6 +19,7 @@ 21 + false @@ -128,9 +129,9 @@ org.apache.maven.plugins maven-surefire-plugin - true + ${skipTests} - \ No newline at end of file + diff --git a/backend/src/main/java/cx/flamingo/analysis/cache/CacheServiceAbs.java b/backend/src/main/java/cx/flamingo/analysis/cache/CacheServiceAbs.java index 6682343..00db4c4 100644 --- a/backend/src/main/java/cx/flamingo/analysis/cache/CacheServiceAbs.java +++ b/backend/src/main/java/cx/flamingo/analysis/cache/CacheServiceAbs.java @@ -83,11 +83,7 @@ public Optional getGitHubApiResponse(City city, String language, int Supplier supplier) { String cacheKey = generateGithubCacheKey(city, language, pageNumber); - fetchFromCache: { - if (forceCacheUpdate()) { - break fetchFromCache; - } - + if (!shouldSkipCache()) { Optional cachedResponse = get(getGithubCachePath(), cacheKey, new TypeToken() { }, githubRefreshIntervalMs); @@ -122,11 +118,7 @@ public Optional> getHttpResponse( String cacheKey = generateCacheKey(cityId, regionId, stateId, teamId, languageId, maxResults); - fetchFromCache: { - if (forceCacheUpdate()) { - break fetchFromCache; - } - + if (!shouldSkipCache()) { Optional> cachedResponse = get(getHttpCachePath(), cacheKey, new TypeToken>() { }, httpRefreshIntervalMs); @@ -147,11 +139,14 @@ public Optional> getHttpResponse( return doHttpCall(supplier, getHttpCachePath(), cacheKey); } + private boolean shouldSkipCache() { + return forceCacheUpdate(); + } + @Async protected CompletableFuture doHttpCallAsync(Supplier supplier, String cachePath, String cacheKey) { - return CompletableFuture.runAsync(() -> { - doHttpCall(supplier, cachePath, cacheKey); - }); + doHttpCall(supplier, cachePath, cacheKey); + return CompletableFuture.completedFuture(null); } protected Optional doHttpCall(Supplier supplier, String cachePath, String cacheKey) { @@ -204,8 +199,7 @@ protected String generateCacheKey(String cityId, String regionId, String stateId protected String generateGithubCacheKey(City city, String language, int pageNumber) { StringBuilder key = new StringBuilder(); - key.append(getDelimiter()) - .append(city.getId()) + key.append(city.getId()) .append(getDelimiter()) .append(language) .append(getDelimiter()) diff --git a/backend/src/main/java/cx/flamingo/analysis/cache/impl/DiskCacheService.java b/backend/src/main/java/cx/flamingo/analysis/cache/impl/DiskCacheService.java index 4a4cf50..a31abda 100644 --- a/backend/src/main/java/cx/flamingo/analysis/cache/impl/DiskCacheService.java +++ b/backend/src/main/java/cx/flamingo/analysis/cache/impl/DiskCacheService.java @@ -1,10 +1,13 @@ package cx.flamingo.analysis.cache.impl; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Optional; import org.springframework.beans.factory.annotation.Value; @@ -53,9 +56,29 @@ protected String getHttpCachePath() { return httpCachePath; } + /** + * Sanitizes a cache key by hashing it with SHA-256 so that user-supplied + * values (GitHub usernames, URLs, etc.) cannot introduce path-traversal + * sequences into the resulting filename. + */ + private String sanitizeKey(String key) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(key.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is guaranteed by the JVM spec; this branch is unreachable in practice. + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + } + @Override protected Long getInsertTime(String cachePath, String key) { - Path filePath = Paths.get(cachePath, key + ".json"); + Path filePath = Paths.get(cachePath, sanitizeKey(key) + ".json"); try { BasicFileAttributes attrs = Files.readAttributes(filePath, BasicFileAttributes.class); return attrs.lastModifiedTime().toMillis(); @@ -76,7 +99,7 @@ private void createCacheDirectory(String path) { @Override public Optional get(String cachePath, String key, TypeToken typeRef, Long refreshInterval) { - Path filePath = Paths.get(cachePath, key + ".json"); + Path filePath = Paths.get(cachePath, sanitizeKey(key) + ".json"); if (!Files.exists(filePath)) { log.debug("Cache miss for key: '{}'", key); @@ -124,7 +147,7 @@ public void put(String cachePath, String key, T value) { return; } - Path filePath = Paths.get(cachePath, key + ".json"); + Path filePath = Paths.get(cachePath, sanitizeKey(key) + ".json"); try { Files.createDirectories(filePath.getParent()); @@ -145,7 +168,7 @@ public void put(String cachePath, String key, T value) { @Override public void invalidate(String cachePath, String key) { - Path filePath = Paths.get(cachePath, key + ".json"); + Path filePath = Paths.get(cachePath, sanitizeKey(key) + ".json"); try { Files.deleteIfExists(filePath); log.info("Invalidated file cache for key: '{}'", key); diff --git a/backend/src/main/java/cx/flamingo/analysis/cache/impl/ReadOnlyCacheService.java b/backend/src/main/java/cx/flamingo/analysis/cache/impl/ReadOnlyCacheService.java index ca59793..16ff277 100644 --- a/backend/src/main/java/cx/flamingo/analysis/cache/impl/ReadOnlyCacheService.java +++ b/backend/src/main/java/cx/flamingo/analysis/cache/impl/ReadOnlyCacheService.java @@ -44,7 +44,10 @@ public Optional get(String cachePath, String key, TypeToken typeRef, L if (cachedValue != null) { try { - T value = gson.fromJson(cachedValue.toString(), typeRef); + String json = (cachedValue instanceof String) + ? (String) cachedValue + : gson.toJson(cachedValue); + T value = gson.fromJson(json, typeRef); log.debug("Read-only cache hit for key: '{}'", redisKey); return Optional.of(value); } catch (Exception e) { @@ -97,4 +100,4 @@ public Optional> getHttpResponse(String cityId, String regionI cityId, regionId, stateId, teamId, languageId, maxResults); return Optional.empty(); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/cx/flamingo/analysis/cache/impl/RedisCacheService.java b/backend/src/main/java/cx/flamingo/analysis/cache/impl/RedisCacheService.java index 1a5e45c..df25b82 100644 --- a/backend/src/main/java/cx/flamingo/analysis/cache/impl/RedisCacheService.java +++ b/backend/src/main/java/cx/flamingo/analysis/cache/impl/RedisCacheService.java @@ -47,6 +47,9 @@ protected String getHttpCachePath() { protected Long getInsertTime(String cachePath, String key) { String redisKey = buildRedisKey(cachePath, key); Object json = valueOps.get(redisKey + EXPIRATION_SUFFIX); + if (json == null) { + return 0L; + } Expiration expiration = gson.fromJson(json.toString(), Expiration.class); if (expiration != null) { return expiration.getTimestamp(); diff --git a/backend/src/main/java/cx/flamingo/analysis/config/AsyncConfig.java b/backend/src/main/java/cx/flamingo/analysis/config/AsyncConfig.java index 1a6f829..d119ae7 100644 --- a/backend/src/main/java/cx/flamingo/analysis/config/AsyncConfig.java +++ b/backend/src/main/java/cx/flamingo/analysis/config/AsyncConfig.java @@ -58,17 +58,11 @@ public void shutdown() { public void shutdownThreadPoolExecutor(ThreadPoolTaskExecutor executor) { if (executor != null) { log.info("Shutting down Contributors thread pool..."); + // Delegate entirely to ThreadPoolTaskExecutor, which honours + // waitForTasksToCompleteOnShutdown and awaitTerminationSeconds (60 s) + // configured above. No manual awaitTermination is needed here and + // adding one would race with / override the configured 60-second wait. executor.shutdown(); - try { - if (!executor.getThreadPoolExecutor().awaitTermination(10, TimeUnit.SECONDS)) { - log.warn("Thread pool did not terminate in time. Forcing shutdown..."); - executor.getThreadPoolExecutor().shutdownNow(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Thread pool shutdown interrupted", e); - executor.getThreadPoolExecutor().shutdownNow(); - } log.info("Thread pool shutdown completed"); } } diff --git a/backend/src/main/java/cx/flamingo/analysis/config/CacheConfig.java b/backend/src/main/java/cx/flamingo/analysis/config/CacheConfig.java index b9dba2f..563c85f 100644 --- a/backend/src/main/java/cx/flamingo/analysis/config/CacheConfig.java +++ b/backend/src/main/java/cx/flamingo/analysis/config/CacheConfig.java @@ -1,6 +1,7 @@ package cx.flamingo.analysis.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -54,29 +55,38 @@ public static CacheImplementation fromString(String impl) { } } - @Value("${cache.implementation:redis}") - private String cacheImplementation; - @Value("${cache.mode:read-write}") private String cacheMode; @Bean @Primary - public CacheServiceAbs cacheService(RedisCacheService redisCache, - DiskCacheService diskCache, - ReadOnlyCacheService readOnlyCache) { + @ConditionalOnProperty(name = "cache.mode", havingValue = "read-only", matchIfMissing = false) + public CacheServiceAbs cacheServiceReadOnly(ReadOnlyCacheService readOnlyCache) { CacheMode mode = CacheMode.fromString(cacheMode); - CacheImplementation impl = CacheImplementation.fromString(cacheImplementation); - redisCache.setCacheMode(mode); - diskCache.setCacheMode(mode); readOnlyCache.setCacheMode(mode); - - log.info("Initializing cache with mode: {} and implementation: {}", mode.getValue(), impl.getValue()); - - if (mode == CacheMode.READ_ONLY) { - return readOnlyCache; - } + log.info("Initializing cache with mode: {} and implementation: read-only", mode.getValue()); + return readOnlyCache; + } - return impl == CacheImplementation.REDIS ? redisCache : diskCache; + @Bean + @Primary + @ConditionalOnProperty(name = "cache.implementation", havingValue = "redis", matchIfMissing = true) + @ConditionalOnProperty(name = "cache.mode", havingValue = "read-only", matchIfMissing = false) + public CacheServiceAbs cacheServiceRedis(RedisCacheService redisCache) { + CacheMode mode = CacheMode.fromString(cacheMode); + redisCache.setCacheMode(mode); + log.info("Initializing cache with mode: {} and implementation: redis", mode.getValue()); + return redisCache; + } + + @Bean + @Primary + @ConditionalOnProperty(name = "cache.implementation", havingValue = "disk") + @ConditionalOnProperty(name = "cache.mode", havingValue = "read-only", matchIfMissing = false) + public CacheServiceAbs cacheServiceDisk(DiskCacheService diskCache) { + CacheMode mode = CacheMode.fromString(cacheMode); + diskCache.setCacheMode(mode); + log.info("Initializing cache with mode: {} and implementation: disk", mode.getValue()); + return diskCache; } -} +} diff --git a/backend/src/main/java/cx/flamingo/analysis/config/CacheUpdaterConfig.java b/backend/src/main/java/cx/flamingo/analysis/config/CacheUpdaterConfig.java index 5c986a0..86e0778 100644 --- a/backend/src/main/java/cx/flamingo/analysis/config/CacheUpdaterConfig.java +++ b/backend/src/main/java/cx/flamingo/analysis/config/CacheUpdaterConfig.java @@ -1,6 +1,5 @@ package cx.flamingo.analysis.config; -import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.EnableScheduling; @@ -8,6 +7,6 @@ @Configuration @Profile("cache-updater") @EnableScheduling -public class CacheUpdaterConfig extends WebMvcAutoConfiguration { +public class CacheUpdaterConfig { // Cache updater specific configuration can be added here -} \ No newline at end of file +} diff --git a/backend/src/main/java/cx/flamingo/analysis/config/RedisConfig.java b/backend/src/main/java/cx/flamingo/analysis/config/RedisConfig.java index 3720b93..30a5b50 100644 --- a/backend/src/main/java/cx/flamingo/analysis/config/RedisConfig.java +++ b/backend/src/main/java/cx/flamingo/analysis/config/RedisConfig.java @@ -22,10 +22,10 @@ @Configuration public class RedisConfig { - @Value("${spring.redis.host:localhost}") + @Value("${spring.data.redis.host:localhost}") private String redisHost; - @Value("${spring.redis.port:6379}") + @Value("${spring.data.redis.port:6379}") private int redisPort; @Bean diff --git a/backend/src/main/java/cx/flamingo/analysis/config/WebConfig.java b/backend/src/main/java/cx/flamingo/analysis/config/WebConfig.java index 5fa1374..fd25baa 100644 --- a/backend/src/main/java/cx/flamingo/analysis/config/WebConfig.java +++ b/backend/src/main/java/cx/flamingo/analysis/config/WebConfig.java @@ -19,14 +19,12 @@ public void addCorsMappings(CorsRegistry registry) { "http://localhost:8450", "http://localhost:3000", // Production origins - "https://www.mlg.soccer", - // Allow the ingress controller origin - "http://www.mlg.soccer" + "https://www.mlg.soccer" ) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") - .allowedHeaders("*") + .allowedHeaders("Content-Type", "Authorization", "X-Requested-With") .exposedHeaders("Access-Control-Allow-Origin") .allowCredentials(true) .maxAge(3600); // 1 hour max age } -} \ No newline at end of file +} diff --git a/backend/src/main/java/cx/flamingo/analysis/controller/ContributorController.java b/backend/src/main/java/cx/flamingo/analysis/controller/ContributorController.java index 96cd169..eee3655 100644 --- a/backend/src/main/java/cx/flamingo/analysis/controller/ContributorController.java +++ b/backend/src/main/java/cx/flamingo/analysis/controller/ContributorController.java @@ -184,7 +184,9 @@ public ResponseEntity exportContributors( Language language = languageId != null ? languageService.getLanguageById(languageId) : languageService.getDefaultLanguage(); - String languageName = language.getName().toLowerCase(); + String languageName = (language != null && language.getName() != null) + ? language.getName().toLowerCase() + : "unknown"; // Build location part of filename String locationPart = "all"; @@ -197,15 +199,20 @@ public ResponseEntity exportContributors( locationPart = regionId; } + // Sanitize filename components to prevent Content-Disposition header injection + String safeLanguageName = languageName.replaceAll("[^a-zA-Z0-9._-]", ""); + String safeLocationPart = locationPart.replaceAll("[^a-zA-Z0-9._-]", ""); + String safeDateStr = dateStr.replaceAll("[^a-zA-Z0-9._-]", ""); + // Construct filename String filename = String.format("mlg-contributors-%s-%s-%s.csv", - languageName, - locationPart, - dateStr + safeLanguageName, + safeLocationPart, + safeDateStr ); return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment; filename=%s", filename)) + .header(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment; filename=\"%s\"", filename)) .contentType(MediaType.parseMediaType("text/csv")) .body(stringWriter.toString()); } diff --git a/backend/src/main/java/cx/flamingo/analysis/exception/ApiError.java b/backend/src/main/java/cx/flamingo/analysis/exception/ApiError.java index e6aad00..6a6a76d 100644 --- a/backend/src/main/java/cx/flamingo/analysis/exception/ApiError.java +++ b/backend/src/main/java/cx/flamingo/analysis/exception/ApiError.java @@ -2,15 +2,19 @@ import java.time.LocalDateTime; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class ApiError { private LocalDateTime timestamp; private int status; private String error; private String message; private String path; -} \ No newline at end of file +} diff --git a/backend/src/main/java/cx/flamingo/analysis/exception/GlobalExceptionHandler.java b/backend/src/main/java/cx/flamingo/analysis/exception/GlobalExceptionHandler.java index 667f895..b41e2ee 100644 --- a/backend/src/main/java/cx/flamingo/analysis/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/cx/flamingo/analysis/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; +import cx.flamingo.analysis.model.ApiResponse; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -15,17 +16,11 @@ public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) - public ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { + public ResponseEntity> handleAllExceptions(Exception ex, WebRequest request) { log.error("Unhandled exception", ex); - ApiError error = ApiError.builder() - .timestamp(LocalDateTime.now()) - .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) - .error(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()) - .message(ex.getMessage()) - .path(request.getDescription(false)) - .build(); - - return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR); + return new ResponseEntity<>( + ApiResponse.error(ex.getMessage()), + HttpStatus.INTERNAL_SERVER_ERROR); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/cx/flamingo/analysis/graphql/GitHubQueryBuilder.java b/backend/src/main/java/cx/flamingo/analysis/graphql/GitHubQueryBuilder.java index e769d21..574f01c 100644 --- a/backend/src/main/java/cx/flamingo/analysis/graphql/GitHubQueryBuilder.java +++ b/backend/src/main/java/cx/flamingo/analysis/graphql/GitHubQueryBuilder.java @@ -31,7 +31,8 @@ public GitHubQueryBuilder language(String language) { public GitHubQueryBuilder cursor(String cursor) { if (cursor != null) { - searchField.withArgs(searchField.getArgs() + ", after: \"" + cursor + "\""); + String escapedCursor = cursor.replace("\\", "\\\\").replace("\"", "\\\""); + searchField.withArgs(searchField.getArgs() + ", after: \"" + escapedCursor + "\""); } return this; } @@ -171,13 +172,16 @@ public SearchField setType(String type, int size) { return this; } + /** @deprecated This method is a no-op stub; page size is set via {@link #setType(String, int)}. */ + @Deprecated public SearchField setFirst(int first) { return this; } public SearchField addLocationFilter(String location) { if (queryFilters.length() > 0) queryFilters.append(" "); - queryFilters.append("location:\"").append(location).append("\""); + String escapedLocation = location.replace("\\", "\\\\").replace("\"", "\\\""); + queryFilters.append("location:\"").append(escapedLocation).append("\""); updateQueryArg(); return this; } diff --git a/backend/src/main/java/cx/flamingo/analysis/graphql/SearchField.java b/backend/src/main/java/cx/flamingo/analysis/graphql/SearchField.java index 8d51d22..aeb335a 100644 --- a/backend/src/main/java/cx/flamingo/analysis/graphql/SearchField.java +++ b/backend/src/main/java/cx/flamingo/analysis/graphql/SearchField.java @@ -13,7 +13,8 @@ public SearchField appendQuery(String queryPart) { queryBuilder.append(" "); } queryBuilder.append(queryPart); - args.put("query", "\"" + queryBuilder.toString() + "\""); + String escaped = queryBuilder.toString().replace("\\", "\\\\").replace("\"", "\\\""); + args.put("query", "\"" + escaped + "\""); return this; } diff --git a/backend/src/main/java/cx/flamingo/analysis/rate/GithubToken.java b/backend/src/main/java/cx/flamingo/analysis/rate/GithubToken.java index 00e7f4d..1e754c9 100644 --- a/backend/src/main/java/cx/flamingo/analysis/rate/GithubToken.java +++ b/backend/src/main/java/cx/flamingo/analysis/rate/GithubToken.java @@ -8,6 +8,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; @Data @Builder @@ -16,6 +17,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class GithubToken { // Token value + @ToString.Exclude private String token; // Primary rate limit fields diff --git a/backend/src/main/java/cx/flamingo/analysis/rate/GithubTokenRateManager.java b/backend/src/main/java/cx/flamingo/analysis/rate/GithubTokenRateManager.java index ce61265..1ce669b 100644 --- a/backend/src/main/java/cx/flamingo/analysis/rate/GithubTokenRateManager.java +++ b/backend/src/main/java/cx/flamingo/analysis/rate/GithubTokenRateManager.java @@ -19,12 +19,10 @@ import org.springframework.web.reactive.function.client.WebClient; import jakarta.annotation.PostConstruct; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; @Slf4j @Service -@Getter public class GithubTokenRateManager { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -59,7 +57,7 @@ public void init() { } - boolean alreadyInitialized = false; + private volatile boolean alreadyInitialized = false; private String formatResetTime(Long resetTimeSeconds) { if (resetTimeSeconds == null) { @@ -126,120 +124,136 @@ public synchronized void initializeRateLimits() { printTokensStatus(); } + private static final int MAX_RETRY_ATTEMPTS = 10; + /** * Returns the WebClient with the most remaining API calls and furthest reset time. * If all tokens are exhausted, waits for the earliest reset time. * @return WebClient with optimal rate limit status */ - public Pair getBestAvailableClient() { - WebClient bestClient = null; - GithubToken bestToken = null; - int maxRemaining = -1; - long latestReset = 0; - long earliestReset = Long.MAX_VALUE; - long earliestSecondaryReset = Long.MAX_VALUE; + public synchronized Pair getBestAvailableClient() { + int attempts = 0; + while (attempts < MAX_RETRY_ATTEMPTS) { + WebClient bestClient = null; + GithubToken bestToken = null; + int maxRemaining = -1; + long latestReset = 0; + long earliestReset = Long.MAX_VALUE; + long earliestSecondaryReset = Long.MAX_VALUE; + + for (Map.Entry> entry : tokenMap.entrySet()) { + GithubToken token = entry.getValue().getValue0(); + WebClient client = entry.getValue().getValue1(); + + // Skip tokens under secondary rate limit + if (token.isUnderSecondaryLimit()) { + long secondaryResetTime = token.getLastSecondaryLimitHit()/1000 + token.getRetryAfterSeconds(); + if (secondaryResetTime < earliestSecondaryReset) { + earliestSecondaryReset = secondaryResetTime; + } + continue; + } - for (Map.Entry> entry : tokenMap.entrySet()) { - GithubToken token = entry.getValue().getValue0(); - WebClient client = entry.getValue().getValue1(); - - // Skip tokens under secondary rate limit - if (token.isUnderSecondaryLimit()) { - long secondaryResetTime = token.getLastSecondaryLimitHit()/1000 + token.getRetryAfterSeconds(); - if (secondaryResetTime < earliestSecondaryReset) { - earliestSecondaryReset = secondaryResetTime; + Integer remaining = token.getRemainingRequests(); + Long resetTime = token.getResetTime(); + + // Skip if we don't have rate limit info + if (remaining == null || resetTime == null) { + continue; } - continue; - } - - Integer remaining = token.getRemainingRequests(); - Long resetTime = token.getResetTime(); - - // Skip if we don't have rate limit info - if (remaining == null || resetTime == null) { - continue; - } - // Track earliest reset time for waiting when all tokens are exhausted - if (resetTime < earliestReset) { - earliestReset = resetTime; - } + // Track earliest reset time for waiting when all tokens are exhausted + if (resetTime < earliestReset) { + earliestReset = resetTime; + } - // If this token has more remaining calls, or same calls but later reset - if (remaining > maxRemaining || - (remaining == maxRemaining && resetTime > latestReset)) { - maxRemaining = remaining; - latestReset = resetTime; - bestClient = client; - bestToken = token; + // If this token has more remaining calls, or same calls but later reset + if (remaining > maxRemaining || + (remaining == maxRemaining && resetTime > latestReset)) { + maxRemaining = remaining; + latestReset = resetTime; + bestClient = client; + bestToken = token; + } } - } - long now = Instant.now().getEpochSecond(); - - // If all tokens are under secondary rate limit, wait for the earliest one - if (bestClient == null && earliestSecondaryReset != Long.MAX_VALUE) { - long waitTime = earliestSecondaryReset - now; - if (waitTime > 0) { - log.info("All tokens under secondary rate limit. Waiting {} seconds until first token available", waitTime); - try { - Thread.sleep(waitTime * 1000); - return getBestAvailableClient(); - } catch (InterruptedException e) { - log.error("Sleep interrupted while waiting for secondary rate limit", e); - Thread.currentThread().interrupt(); + long now = Instant.now().getEpochSecond(); + + // If all tokens are under secondary rate limit, wait for the earliest one + if (bestClient == null && earliestSecondaryReset != Long.MAX_VALUE) { + long waitTime = earliestSecondaryReset - now; + if (waitTime > 0) { + log.info("All tokens under secondary rate limit. Waiting {} seconds until first token available", waitTime); + try { + Thread.sleep(waitTime * 1000); + } catch (InterruptedException e) { + log.error("Sleep interrupted while waiting for secondary rate limit", e); + Thread.currentThread().interrupt(); + return null; + } } + attempts++; + continue; } - } - // If all tokens are exhausted (maxRemaining == 0), wait for the earliest reset - if (maxRemaining == 0 && earliestReset != Long.MAX_VALUE) { - long waitTime = earliestReset - now; - if (waitTime > 0) { - log.info("All tokens exhausted. Waiting {} seconds until first token refresh at {}", - waitTime, formatResetTime(earliestReset)); - try { - Thread.sleep(waitTime * 1000); - initializeRateLimits(); - return getBestAvailableClient(); - } catch (InterruptedException e) { - log.error("Sleep interrupted while waiting for token refresh", e); - Thread.currentThread().interrupt(); + // If all tokens are exhausted (maxRemaining == 0), wait for the earliest reset + if (maxRemaining == 0 && earliestReset != Long.MAX_VALUE) { + long waitTime = earliestReset - now; + if (waitTime > 0) { + log.info("All tokens exhausted. Waiting {} seconds until first token refresh at {}", + waitTime, formatResetTime(earliestReset)); + try { + Thread.sleep(waitTime * 1000); + } catch (InterruptedException e) { + log.error("Sleep interrupted while waiting for token refresh", e); + Thread.currentThread().interrupt(); + return null; + } } + alreadyInitialized = false; + initializeRateLimits(); + attempts++; + continue; } - } - // If best token needs to wait for primary rate limit reset, wait - if (bestToken != null && maxRemaining == 0) { - long waitTime = bestToken.getSecondsUntilReset(); - if (waitTime > 0) { - log.info("Best token needs to wait {} seconds until rate limit reset", waitTime); - try { - Thread.sleep(waitTime * 1000); - initializeRateLimits(); - return getBestAvailableClient(); - } catch (InterruptedException e) { - log.error("Sleep interrupted while waiting for token reset", e); - Thread.currentThread().interrupt(); + // If best token needs to wait for primary rate limit reset, wait + if (bestToken != null && maxRemaining == 0) { + long waitTime = bestToken.getSecondsUntilReset(); + if (waitTime > 0) { + log.info("Best token needs to wait {} seconds until rate limit reset", waitTime); + try { + Thread.sleep(waitTime * 1000); + } catch (InterruptedException e) { + log.error("Sleep interrupted while waiting for token reset", e); + Thread.currentThread().interrupt(); + return null; + } } + alreadyInitialized = false; + initializeRateLimits(); + attempts++; + continue; } - } - // If no client found with rate info, return the first one - if (bestClient == null && !tokenMap.isEmpty()) { - log.warn("No rate limit information available, returning first available client"); - bestClient = tokenMap.values().iterator().next().getValue1(); - bestToken = tokenMap.values().iterator().next().getValue0(); - } + // If no client found with rate info, return the first one + if (bestClient == null && !tokenMap.isEmpty()) { + log.warn("No rate limit information available, returning first available client"); + bestClient = tokenMap.values().iterator().next().getValue1(); + bestToken = tokenMap.values().iterator().next().getValue0(); + } + + if (bestClient != null) { + log.debug("Selected token {} with {} remaining calls, reset at {}", + bestToken.getToken().substring(0, 8), + maxRemaining, + formatResetTime(latestReset)); + } - if (bestClient != null) { - log.debug("Selected token {} with {} remaining calls, reset at {}", - bestToken.getToken().substring(0, 8), - maxRemaining, - formatResetTime(latestReset)); + return Pair.with(bestClient, bestToken); } - return Pair.with(bestClient, bestToken); + log.error("getBestAvailableClient() exceeded maximum retry attempts ({})", MAX_RETRY_ATTEMPTS); + return null; } /** @@ -288,12 +302,12 @@ public void updateTokenRateLimits(GithubToken token, Map> h token.getRateLimit(), formatResetTime(token.getResetTime()), token.getUsedRequests()); - + if (retryAfter != null) { log.debug("Token {} - Secondary rate limit hit: Retry-After={}, Last hit={}", token.getToken().substring(0, 8), token.getRetryAfterSeconds(), - token.getLastSecondaryLimitHit() != null ? + token.getLastSecondaryLimitHit() != null ? Instant.ofEpochMilli(token.getLastSecondaryLimitHit()).toString() : "N/A"); } } @@ -306,5 +320,3 @@ private String getFirstHeader(Map> headers, String headerNa return values != null && !values.isEmpty() ? values.get(0) : null; } } - - diff --git a/backend/src/main/java/cx/flamingo/analysis/service/PreCacheService.java b/backend/src/main/java/cx/flamingo/analysis/service/PreCacheService.java index e1db75b..af714b9 100644 --- a/backend/src/main/java/cx/flamingo/analysis/service/PreCacheService.java +++ b/backend/src/main/java/cx/flamingo/analysis/service/PreCacheService.java @@ -4,30 +4,27 @@ import java.time.Instant; import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import cx.flamingo.analysis.cache.CacheServiceAbs; -import cx.flamingo.analysis.controller.ContributorController; import cx.flamingo.analysis.model.Language; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Service +@RequiredArgsConstructor public class PreCacheService { - @Autowired - ContributorController contributorController; + private final ContributorService contributorService; - @Autowired - LanguageService languageService; + private final LanguageService languageService; - @Autowired - CacheServiceAbs cacheService; + private final CacheServiceAbs cacheService; // Always run the cache refresh cycle on startup - @Scheduled(initialDelay = 1000l, fixedDelay = 1000l) + @Scheduled(initialDelay = 1000l, fixedDelay = 3600000l) void runFullCacheCycle() { Instant startTime = Instant.now(); log.info("Starting cache refresh cycle for all languages..."); @@ -37,7 +34,7 @@ void runFullCacheCycle() { try { log.info("Refreshing cache for language {}", language.getName()); // Force cache refresh for all cities - contributorController.getContributors(null, null, null, null, language.getId(), 15, + contributorService.getContributors(null, null, null, null, language.getId(), 15, GithubService.GithubApiPriority.Low); } catch (Exception e) { log.error("Error fetching contributors for language {}: {}", language.getName(), e.getMessage()); diff --git a/backend/src/main/java/cx/flamingo/analysis/service/ReferencePopulationService.java b/backend/src/main/java/cx/flamingo/analysis/service/ReferencePopulationService.java index 600e0e0..e313a6d 100644 --- a/backend/src/main/java/cx/flamingo/analysis/service/ReferencePopulationService.java +++ b/backend/src/main/java/cx/flamingo/analysis/service/ReferencePopulationService.java @@ -3,30 +3,35 @@ import java.util.Set; import java.util.stream.Collectors; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import cx.flamingo.analysis.model.City; import cx.flamingo.analysis.model.Region; import cx.flamingo.analysis.model.State; -import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Service +@RequiredArgsConstructor public class ReferencePopulationService { - @Autowired - private CityService cityService; + private final CityService cityService; - @Autowired - private RegionService regionService; + private final RegionService regionService; - @Autowired - private StateService stateService; - - @PostConstruct - public void init() { + private final StateService stateService; + + private volatile boolean initialized = false; + + @EventListener(ContextRefreshedEvent.class) + public synchronized void init() { + if (initialized) { + return; + } + initialized = true; populateReferences(); } @@ -42,8 +47,12 @@ private void populateRegionReferences() { Set states = region.getStateIds().stream() .map(stateService::getStateByCode) .filter(state -> state != null) - .peek(state -> state.getRegionIds().add(region.getId())) .collect(Collectors.toSet()); + + // Mutate state objects in an explicit loop, not via peek() + for (State state : states) { + state.getRegionIds().add(region.getId()); + } // Fill cities Set cities = cityService.getAllCities().stream() @@ -65,4 +74,4 @@ private void populateRegionReferences() { regionService.updateRegion(filledRegion); } } -} \ No newline at end of file +} diff --git a/backend/src/main/java/cx/flamingo/analysis/service/SoccerTeamService.java b/backend/src/main/java/cx/flamingo/analysis/service/SoccerTeamService.java index 366a3a4..9608e03 100644 --- a/backend/src/main/java/cx/flamingo/analysis/service/SoccerTeamService.java +++ b/backend/src/main/java/cx/flamingo/analysis/service/SoccerTeamService.java @@ -5,6 +5,7 @@ import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import org.springframework.core.io.ClassPathResource; @@ -18,6 +19,7 @@ @Slf4j @Service public class SoccerTeamService { + private static final int EXPECTED_COLUMNS = 14; private List teams; @PostConstruct @@ -35,41 +37,52 @@ private void loadTeams() { reader.readLine(); String line; + int lineNumber = 1; while ((line = reader.readLine()) != null) { - String[] parts = line.split(","); - String id = parts[0]; - String name = parts[1]; - String city = parts[2]; - String state = parts[3]; - double latitude = Double.parseDouble(parts[4]); - double longitude = Double.parseDouble(parts[5]); - String league = parts[6]; - String stadium = parts[7]; - int stadiumCapacity = Integer.parseInt(parts[8]); - int joinedYear = Integer.parseInt(parts[9]); - String headCoach = parts[10]; - String teamUrl = parts[11]; - String wikipediaUrl = parts[12]; - String logoUrl = parts[13]; - - SoccerTeam team = SoccerTeam.builder() - .id(id) - .name(name) - .city(city) - .state(state) - .latitude(latitude) - .longitude(longitude) - .league(league) - .stadium(stadium) - .stadiumCapacity(stadiumCapacity) - .joinedYear(joinedYear) - .headCoach(headCoach) - .teamUrl(teamUrl) - .wikipediaUrl(wikipediaUrl) - .logoUrl(logoUrl) - .build(); - - teams.add(team); + lineNumber++; + String[] parts = line.split(",", -1); + if (parts.length < EXPECTED_COLUMNS) { + log.warn("Skipping malformed CSV row at line {} (expected {} columns, got {}): {}", + lineNumber, EXPECTED_COLUMNS, parts.length, line); + continue; + } + try { + String id = parts[0]; + String name = parts[1]; + String city = parts[2]; + String state = parts[3]; + double latitude = Double.parseDouble(parts[4]); + double longitude = Double.parseDouble(parts[5]); + String league = parts[6]; + String stadium = parts[7]; + int stadiumCapacity = Integer.parseInt(parts[8]); + int joinedYear = Integer.parseInt(parts[9]); + String headCoach = parts[10]; + String teamUrl = parts[11]; + String wikipediaUrl = parts[12]; + String logoUrl = parts[13]; + + SoccerTeam team = SoccerTeam.builder() + .id(id) + .name(name) + .city(city) + .state(state) + .latitude(latitude) + .longitude(longitude) + .league(league) + .stadium(stadium) + .stadiumCapacity(stadiumCapacity) + .joinedYear(joinedYear) + .headCoach(headCoach) + .teamUrl(teamUrl) + .wikipediaUrl(wikipediaUrl) + .logoUrl(logoUrl) + .build(); + + teams.add(team); + } catch (NumberFormatException e) { + log.warn("Skipping CSV row at line {} due to number parse error: {}", lineNumber, e.getMessage()); + } } } catch (IOException e) { log.error("Error loading teams from CSV", e); @@ -100,11 +113,10 @@ public String findNearestTeamId(City city) { return nearest != null ? nearest.getId() : null; } - public SoccerTeam getTeamById(String id) { + public Optional getTeamById(String id) { return teams.stream() .filter(t -> t.getId().equals(id)) - .findFirst() - .orElse(null); + .findFirst(); } private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { @@ -121,7 +133,7 @@ private double calculateDistance(double lat1, double lon1, double lat2, double l } public List getAllTeams() { - return teams; + return new ArrayList<>(teams); } public List autocompleteTeams(String query, int maxResults) { @@ -142,4 +154,4 @@ public List autocompleteTeams(String query, int maxResults) { .limit(maxResults) .collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh index c473c97..423c356 100644 --- a/frontend/docker-entrypoint.sh +++ b/frontend/docker-entrypoint.sh @@ -13,7 +13,7 @@ echo "Debug: ROOT_DOMAIN='$ROOT_DOMAIN'" # Process redirect block conditionally if [ "$ENABLE_REDIRECT" = "true" ] && [ -n "$ROOT_DOMAIN" ]; then echo "Enabling redirect from $ROOT_DOMAIN to $TARGET_DOMAIN" - export REDIRECT_SERVER_BLOCK=$(envsubst < /etc/nginx/templates/nginx-redirect.conf.template) + export REDIRECT_SERVER_BLOCK=$(envsubst '$ROOT_DOMAIN $TARGET_DOMAIN' < /etc/nginx/templates/nginx-redirect.conf.template) else echo "Redirect disabled or ROOT_DOMAIN not set" export REDIRECT_SERVER_BLOCK="" @@ -27,4 +27,4 @@ echo "Final nginx configuration:" cat /etc/nginx/conf.d/default.conf # Execute CMD -exec "$@" \ No newline at end of file +exec "$@" diff --git a/frontend/package.json b/frontend/package.json index 7791617..ea6f62b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "type-check": "tsc --noEmit", "generate-favicon": "node scripts/generate-favicon.js", - "setup:ui-kit": "if [ ! -d \"ui-kit\" ]; then echo 'Cloning openframe-oss-lib...' && git clone --depth 1 --filter=blob:none --sparse https://github.com/flamingo-stack/openframe-oss-lib.git temp-oss-lib && cd temp-oss-lib && git sparse-checkout set openframe-frontend-core && cd .. && mv temp-oss-lib/openframe-frontend-core ui-kit && rm -rf temp-oss-lib && cd ui-kit && npm install; elif [ ! -d \"ui-kit/node_modules\" ]; then echo 'ui-kit exists but no node_modules, installing...' && cd ui-kit && npm install; else echo 'ui-kit already set up'; fi", + "setup:ui-kit": "if [ ! -d \"ui-kit\" ]; then echo 'Cloning openframe-oss-lib...' && git clone --depth 1 --filter=blob:none --sparse --branch v1.0.0 --single-branch https://github.com/flamingo-stack/openframe-oss-lib.git temp-oss-lib && cd temp-oss-lib && git sparse-checkout set openframe-frontend-core && cd .. && mv temp-oss-lib/openframe-frontend-core ui-kit && rm -rf temp-oss-lib && cd ui-kit && npm install; elif [ ! -d \"ui-kit/node_modules\" ]; then echo 'ui-kit exists but no node_modules, installing...' && cd ui-kit && npm install; else echo 'ui-kit already set up'; fi", "copy:colors": "node scripts/extract-ui-kit-colors.js" }, "dependencies": { diff --git a/frontend/scripts/extract-ui-kit-colors.js b/frontend/scripts/extract-ui-kit-colors.js index 163090f..8deeb2f 100644 --- a/frontend/scripts/extract-ui-kit-colors.js +++ b/frontend/scripts/extract-ui-kit-colors.js @@ -40,19 +40,42 @@ function extractColorsFromJSON() { function extractSemanticColorsFromCSS() { const cssContent = fs.readFileSync(COLOR_CSS_FILE, 'utf8'); const semanticColors = {}; - - // Extract CSS custom properties (--color-* variables) + + // First pass: collect all raw values for every --color-* variable + const rawValues = {}; const cssVariableRegex = /--color-([^:]+):\s*([^;]+);/g; let match; - + while ((match = cssVariableRegex.exec(cssContent)) !== null) { const [, name, value] = match; - // Only extract hex values, skip var() references - if (value.startsWith('#')) { - semanticColors[name.replace(/-/g, '_')] = value.trim(); + rawValues[name.trim()] = value.trim(); + } + + // Resolve a single raw value, following var() references up to maxDepth hops + function resolve(value, depth) { + if (depth > 10) return null; // guard against circular references + const trimmed = value.trim(); + if (trimmed.startsWith('#')) return trimmed; + // Match var(--color-some-name) or var(--color-some-name, fallback) + const varMatch = trimmed.match(/^var\(\s*(--color-[^,)]+)/); + if (varMatch) { + const referencedName = varMatch[1].replace(/^--color-/, '').trim(); + const referencedValue = rawValues[referencedName]; + if (referencedValue !== undefined) { + return resolve(referencedValue, depth + 1); + } } + // Not a hex and not a resolvable var() โ€” skip + return null; } - + + for (const [name, value] of Object.entries(rawValues)) { + const resolved = resolve(value, 0); + if (resolved !== null) { + semanticColors[name.replace(/-/g, '_')] = resolved; + } + } + return semanticColors; } @@ -188,4 +211,4 @@ if (require.main === module) { generateColorsFile(); } -module.exports = { generateColorsFile }; \ No newline at end of file +module.exports = { generateColorsFile }; diff --git a/frontend/src/components/ContributorsTable/components/ContributorInfo.tsx b/frontend/src/components/ContributorsTable/components/ContributorInfo.tsx index b9c6a9f..014024d 100644 --- a/frontend/src/components/ContributorsTable/components/ContributorInfo.tsx +++ b/frontend/src/components/ContributorsTable/components/ContributorInfo.tsx @@ -44,7 +44,9 @@ const getSocialIcon = (platform: string) => { return ; default: // Fallback to website icon for unknown platforms - console.log(`Unknown social platform: ${platform}`); + if (process.env.NODE_ENV !== 'production') { + console.log(`Unknown social platform: ${platform}`); + } return ; } }; @@ -314,4 +316,4 @@ export const ContributorInfo: React.FC = ({ contributor, index, hiringMan ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ContributorsTable/components/MobileView.tsx b/frontend/src/components/ContributorsTable/components/MobileView.tsx index 7860b3c..c5a427b 100644 --- a/frontend/src/components/ContributorsTable/components/MobileView.tsx +++ b/frontend/src/components/ContributorsTable/components/MobileView.tsx @@ -16,6 +16,7 @@ import ForkRightIcon from '@mui/icons-material/ForkRight'; import UpdateIcon from '@mui/icons-material/Update'; import LinkedInIcon from '@mui/icons-material/LinkedIn'; import TwitterIcon from '@mui/icons-material/Twitter'; +import CloseIcon from '@mui/icons-material/Close'; import { GitHubIcon } from '@flamingo/ui-kit/components/icons'; import FacebookIcon from '@mui/icons-material/Facebook'; import InstagramIcon from '@mui/icons-material/Instagram'; @@ -37,12 +38,18 @@ const getSocialIcon = (platform: string) => { return ; case 'twitter': return ; + case 'x': + return ; case 'github': return ; case 'facebook': return ; case 'instagram': return ; + case 'mastodon': + return ; + case 'bluesky': + return ; case 'email': return ; case 'website': @@ -147,7 +154,7 @@ export const MobileView: React.FC = ({ contributors, isL link.platform === 'github')?.url.split('/').pop()} + hiringManagerUsername={hiringManager?.socialLinks.find(link => link.platform === 'github')?.url.replace(/\/$/, '').split('/').pop()} /> @@ -199,4 +206,4 @@ export const MobileView: React.FC = ({ contributors, isL ))} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ContributorsTable/components/StatsDisplay.tsx b/frontend/src/components/ContributorsTable/components/StatsDisplay.tsx index 005dd2f..143cd35 100644 --- a/frontend/src/components/ContributorsTable/components/StatsDisplay.tsx +++ b/frontend/src/components/ContributorsTable/components/StatsDisplay.tsx @@ -11,6 +11,28 @@ import { formatNumber } from '../utils'; import { githubToOds } from '../../../styles/colorMappings'; export const StatsDisplay: React.FC = ({ contributor }) => { + const formatLatestCommitDate = (): string => { + const d = contributor.latestCommitDate; + if (!d || d.length < 3) { + return 'N/A'; + } + const date = new Date(Date.UTC( + Number(d[0]), + Number(d[1]) - 1, + Number(d[2]), + d.length > 3 ? Number(d[3]) : 0, + d.length > 4 ? Number(d[4]) : 0 + )); + if (isNaN(date.getTime())) { + return 'N/A'; + } + return date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric' + }).replace(/\//g, '/'); + }; + const stats = [ { icon: , @@ -39,17 +61,7 @@ export const StatsDisplay: React.FC = ({ contributor }) => { }, { icon: , - value: new Date(Date.UTC( - Number(contributor.latestCommitDate[0]), - Number(contributor.latestCommitDate[1]) - 1, - Number(contributor.latestCommitDate[2]), - Number(contributor.latestCommitDate[3]), - Number(contributor.latestCommitDate[4]) - )).toLocaleDateString('en-US', { - month: 'numeric', - day: 'numeric', - year: 'numeric' - }).replace(/\//g, '/'), + value: formatLatestCommitDate(), label: 'Last Active' } ]; @@ -92,4 +104,4 @@ export const StatsDisplay: React.FC = ({ contributor }) => { ))} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/FiltersPanel.tsx b/frontend/src/components/FiltersPanel.tsx index f1c5386..a03dfe7 100644 --- a/frontend/src/components/FiltersPanel.tsx +++ b/frontend/src/components/FiltersPanel.tsx @@ -8,7 +8,7 @@ import { TeamAutocomplete } from './TeamAutocomplete'; import { RegionAutocomplete } from './RegionAutocomplete'; import { StateAutocomplete } from './StateAutocomplete'; import { CityAutocomplete } from './CityAutocomplete'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { getLanguageById, getTeamById, getRegionById, getStateById, getCityById, autocompleteLanguages } from '../services/api'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; @@ -22,6 +22,19 @@ const dismissKeyboard = () => { } }; +// Utility function to detect Mac platform without deprecated navigator.platform +const isMacPlatform = (): boolean => { + if (navigator.userAgentData) { + // userAgentData.platform is synchronous for high-entropy hints on Chromium + // It may be empty string on non-Chromium; fall through to userAgent check + const p = (navigator.userAgentData as { platform?: string }).platform; + if (p) { + return p.toLowerCase().includes('mac'); + } + } + return /Mac/i.test(navigator.userAgent); +}; + export const FiltersPanel = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -45,13 +58,17 @@ export const FiltersPanel = () => { const [stateInput, setStateInput] = useState(''); const [cityInput, setCityInput] = useState(''); + // Ref to capture the initial urlState snapshot so the effect is not stale + const initialUrlStateRef = useRef(urlState); + // Load initial state from URL useEffect(() => { + const initialState = initialUrlStateRef.current; const loadInitialState = async () => { try { // Load language or set default to Java - if (urlState.languageId) { - const language = await getLanguageById(urlState.languageId); + if (initialState.languageId) { + const language = await getLanguageById(initialState.languageId); setSelectedLanguage(language); setLanguageInput(language.displayName); } else { @@ -62,43 +79,43 @@ export const FiltersPanel = () => { setSelectedLanguage(java); setLanguageInput(java.displayName); updateUrlState({ - ...urlState, + ...initialState, languageId: java.id }); } } // Load team - if (urlState.teamId) { - const team = await getTeamById(urlState.teamId); + if (initialState.teamId) { + const team = await getTeamById(initialState.teamId); setSelectedTeam(team); setTeamInput(team.name); } // Load region - if (urlState.selectedRegionId) { - const region = await getRegionById(urlState.selectedRegionId); + if (initialState.selectedRegionId) { + const region = await getRegionById(initialState.selectedRegionId); setSelectedRegion(region); setRegionInput(region.displayName); } // Load state - if (urlState.stateId) { - const state = await getStateById(urlState.stateId); + if (initialState.stateId) { + const state = await getStateById(initialState.stateId); setSelectedState(state); setStateInput(state.name); } // Load city - if (urlState.selectedCityId) { - const city = await getCityById(urlState.selectedCityId); + if (initialState.selectedCityId) { + const city = await getCityById(initialState.selectedCityId); setSelectedCity(city); setCityInput(city.name); } } catch (error) { console.error('Error loading initial state:', error); // Only clear the problematic ID from URL state - const newState = { ...urlState }; + const newState = { ...initialState }; if (error instanceof Error && error.message.includes('language')) { newState.languageId = null; } @@ -123,18 +140,9 @@ export const FiltersPanel = () => { useEffect(() => { const handleKeyPress = (event: KeyboardEvent) => { - console.log('Key pressed:', { - key: event.key, - altKey: event.altKey, - metaKey: event.metaKey, - code: event.code, - keyCode: event.keyCode - }); - // Check if it's Option+E on Mac (using event.code) - if ((navigator.platform.includes('Mac') && event.altKey && event.code === 'KeyE') || - (!navigator.platform.includes('Mac') && event.altKey && event.key.toLowerCase() === 'e')) { - console.log('Export shortcut triggered!'); + if ((isMacPlatform() && event.altKey && event.code === 'KeyE') || + (!isMacPlatform() && event.altKey && event.key.toLowerCase() === 'e')) { event.preventDefault(); setShowSizeSelection(true); setExportDialogOpen(true); @@ -581,4 +589,4 @@ export const FiltersPanel = () => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/GitHubStats.tsx b/frontend/src/components/GitHubStats.tsx index 086d4e8..1acf36f 100644 --- a/frontend/src/components/GitHubStats.tsx +++ b/frontend/src/components/GitHubStats.tsx @@ -10,6 +10,7 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import UpdateIcon from '@mui/icons-material/Update'; import CodeIcon from '@mui/icons-material/Code'; import ListAltIcon from '@mui/icons-material/ListAlt'; +import { formatDate } from '../utils/date'; interface GitHubStats { score: number; @@ -68,19 +69,6 @@ export const GitHubStats: React.FC = ({ stats, lastActive }) = return score.toString(); }; - const formatDate = (dateString: string | undefined) => { - if (!dateString) return 'N/A'; - const date = new Date(parseInt(dateString) * 1000); - if (isNaN(date.getTime())) { - return 'N/A'; - } - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }); - }; - if (!stats) { return null; } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3275e06..08000d6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -11,7 +11,7 @@ interface LayoutProps { } export const Layout: React.FC = ({ children }) => { - const { hiringManager, jobOpenings, isLoading: isLoadingHiring } = useHiring(); + const { hiringManager, jobOpenings, isLoading: isLoadingHiring, error: hiringError } = useHiring(); return ( = ({ children }) => { > {children} - {!isLoadingHiring && hiringManager && jobOpenings && ( - - - - - - )} + + + + + ); -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useHiring/index.ts b/frontend/src/hooks/useHiring/index.ts index 68b1d50..791f7ae 100644 --- a/frontend/src/hooks/useHiring/index.ts +++ b/frontend/src/hooks/useHiring/index.ts @@ -7,7 +7,7 @@ export const useHiring = () => { isLoading: isLoadingProfile, error: profileError } = useQuery({ - queryKey: ['hiringManager'], + queryKey: ['hiringManager', 'fetchHiringManagerProfile'], queryFn: fetchHiringManagerProfile, staleTime: 5 * 60 * 1000, // Cache for 5 minutes retry: (failureCount, error) => { @@ -25,7 +25,7 @@ export const useHiring = () => { isLoading: isLoadingJobs, error: jobsError } = useQuery({ - queryKey: ['jobOpenings'], + queryKey: ['jobOpenings', 'fetchJobOpenings'], queryFn: fetchJobOpenings, staleTime: 5 * 60 * 1000, // Cache for 5 minutes retry: (failureCount, error) => { @@ -44,4 +44,4 @@ export const useHiring = () => { isLoading: isLoadingProfile || isLoadingJobs, error: profileError || jobsError }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useUrlState.ts b/frontend/src/hooks/useUrlState.ts index 1187f30..f28ec3a 100644 --- a/frontend/src/hooks/useUrlState.ts +++ b/frontend/src/hooks/useUrlState.ts @@ -73,9 +73,7 @@ class UrlStateError extends Error { function validateValue(value: string, config: ParamConfig): boolean { if (!config.validate) return true; - return config.transform - ? config.validate(config.transform(value)) - : config.validate(value); + return config.validate(value); } /** @@ -128,6 +126,12 @@ export const useUrlState = (options: UseUrlStateOptions = {}) => { const [searchParams, setSearchParams] = useSearchParams(); const debouncedUpdateRef = useRef>(); const previousStateRef = useRef(null); + const onErrorRef = useRef(options.onError); + + // Keep onErrorRef current without adding onError to memo deps + useEffect(() => { + onErrorRef.current = options.onError; + }); // Parse and validate URL state const urlState = useMemo(() => { @@ -137,14 +141,14 @@ export const useUrlState = (options: UseUrlStateOptions = {}) => { const value = parseUrlValue(searchParams.get(config.key), config); state[stateKey] = value; } catch (error) { - if (error instanceof UrlStateError && options.onError) { - options.onError(error); + if (error instanceof UrlStateError && onErrorRef.current) { + onErrorRef.current(error); } state[stateKey] = config.defaultValue; } }); return state; - }, [searchParams, options.onError]); + }, [searchParams]); // Cleanup debounce timeout useEffect(() => { @@ -188,10 +192,10 @@ export const useUrlState = (options: UseUrlStateOptions = {}) => { } }; - // For input changes, we want to update immediately to prevent typing lag + // For input changes (non-null values = typing), we want to update immediately to prevent typing lag const isInputChange = Object.keys(newState).some(key => ['languageId', 'teamId', 'stateId', 'selectedRegionId', 'selectedCityId'].includes(key) && - newState[key as keyof UrlState] === null + newState[key as keyof UrlState] !== null ); if (immediate || isInputChange || !options.debounceMs) { @@ -206,20 +210,17 @@ export const useUrlState = (options: UseUrlStateOptions = {}) => { setSearchParams(new URLSearchParams(), { replace: true }); }, [setSearchParams]); - // Check if state has changed - const hasStateChanged = useMemo(() => { - if (!previousStateRef.current) { - previousStateRef.current = urlState; - return false; - } - - const hasChanged = Object.entries(urlState).some(([key, value]) => { + // Check if state has changed by comparing to previousStateRef without mutating inside useMemo + const hasStateChanged = previousStateRef.current + ? Object.entries(urlState).some(([key, value]) => { const prevValue = previousStateRef.current![key as UrlStateKey]; return value !== prevValue; - }); + }) + : false; + // Update previousStateRef after render as a proper side effect + useEffect(() => { previousStateRef.current = urlState; - return hasChanged; }, [urlState]); return { @@ -231,4 +232,4 @@ export const useUrlState = (options: UseUrlStateOptions = {}) => { Object.values(urlState).every(value => value === null), [urlState]) }; -}; \ No newline at end of file +}; diff --git a/frontend/src/styles/colors.ts b/frontend/src/styles/colors.ts index 79cf06d..824da47 100644 --- a/frontend/src/styles/colors.ts +++ b/frontend/src/styles/colors.ts @@ -60,20 +60,24 @@ export const baseColors = { } as const; // Semantic Colors (hex values from ods-colors.css) +// NOTE: Values below were manually corrected from placeholder values (#000000, #ffffff, #00ff00) +// that were silently left by extract-ui-kit-colors.js when CSS var() references could not be resolved. +// These values are mapped to the nearest ODS base tokens. Re-run the generation script after +// fixing it to resolve var() references, and verify these values against the actual ODS spec. export const semanticColors = { text_on_accent: '#1a1a1a', warning: '#f59e0b', warning_hover: '#d97706', warning_active: '#b45309', link_visited: '#b794f6', - bg_card: '#000000', - border_default: '#ffffff', + bg_card: '#212121', // was '#000000' (placeholder) โ€” mapped to system_greys_black + border_default: '#3a3a3a', // was '#ffffff' (placeholder) โ€” mapped to system_greys_soft_grey divider: '#e5e5e5', - bg: '#000000', - text_primary: '#ffffff', - text_secondary: '#ffffff', - accent_primary: '#00ff00', - focus_ring: '#00ff00', + bg: '#161616', // was '#000000' (placeholder) โ€” mapped to system_greys_background + text_primary: '#fafafa', // was '#ffffff' (placeholder) โ€” mapped to system_greys_white + text_secondary: '#888888', // was '#ffffff' (placeholder) โ€” mapped to system_greys_grey + accent_primary: '#5efaf0', // was '#00ff00' (placeholder) โ€” mapped to flamingo_cyan_base + focus_ring: '#5efaf0', // was '#00ff00' (placeholder) โ€” mapped to flamingo_cyan_base } as const; // Combined colors export diff --git a/frontend/src/types/enhanced.ts b/frontend/src/types/enhanced.ts index 0da4433..c5c6d09 100644 --- a/frontend/src/types/enhanced.ts +++ b/frontend/src/types/enhanced.ts @@ -7,12 +7,12 @@ export interface EnhancedCity extends Omit { } export interface EnhancedRegion extends Omit { - states: Set; - cities: Set; + states: State[]; + cities: City[]; } export interface EnhancedState extends State { regionIds: string[]; - regions: Set; - cities: Set; -} \ No newline at end of file + regions: Region[]; + cities: City[]; +} diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 46340d7..d6b93d6 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -111,7 +111,11 @@ module.exports = (env, argv) => { size: 60 }), new webpack.DefinePlugin({ - 'process.env': JSON.stringify(process.env), + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), + 'process.env.BACKEND_API_URL': JSON.stringify(process.env.BACKEND_API_URL || 'http://localhost:8080'), + 'process.env.GTM_ID': JSON.stringify(process.env.GTM_ID || ''), + 'process.env.WEBAPP_EXTRA_BUTTON_LINK': JSON.stringify(process.env.WEBAPP_EXTRA_BUTTON_LINK || '/blog/why-we-built-mlg'), + 'process.env.WEBAPP_EXTRA_BUTTON_TEXT': JSON.stringify(process.env.WEBAPP_EXTRA_BUTTON_TEXT || 'Why MLG?'), }), new CopyWebpackPlugin({ patterns: [ @@ -201,4 +205,4 @@ module.exports = (env, argv) => { }, devtool: isProduction ? false : 'source-map', }; -}; \ No newline at end of file +}; diff --git a/kubernetes/base/backend-service.yaml b/kubernetes/base/backend-service.yaml index 7e68af7..5c798ea 100644 --- a/kubernetes/base/backend-service.yaml +++ b/kubernetes/base/backend-service.yaml @@ -19,10 +19,19 @@ spec: labels: app: ${SERVICE_NAME} spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault containers: - name: ${SERVICE_NAME} image: ghcr.io/flamingo-stack/major-league-github/${SERVICE_NAME}:${VERSION} imagePullPolicy: Always + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL envFrom: - configMapRef: name: app-config @@ -62,6 +71,14 @@ spec: periodSeconds: 10 timeoutSeconds: 10 failureThreshold: 30 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: ${SERVER_PORT} + initialDelaySeconds: 90 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness @@ -101,4 +118,4 @@ spec: - name: http port: ${SERVER_PORT} targetPort: ${SERVER_PORT} - type: ClusterIP \ No newline at end of file + type: ClusterIP diff --git a/kubernetes/base/ingress.yaml b/kubernetes/base/ingress.yaml index 181b64c..bda2c3e 100644 --- a/kubernetes/base/ingress.yaml +++ b/kubernetes/base/ingress.yaml @@ -21,7 +21,7 @@ metadata: name: main-ingress annotations: kubernetes.io/ingress.class: "gce" - kubernetes.io/ingress.global-static-ip-name: "main-ingress-ip" + kubernetes.io/ingress.global-static-ip-name: "${STATIC_IP_NAME}" networking.gke.io/managed-certificates: "main-ingress-ssl-cert" networking.gke.io/v1beta1.FrontendConfig: "main-ingress-frontend-config" spec: