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: