Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/test-build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
distribution: 'temurin'
cache: maven

- name: Check formatting with Spotless
run: cd server/${{ matrix.service }} && ./mvnw -B spotless:check

- name: Run tests with Maven
run: cd server/${{ matrix.service }} && ./mvnw -B test

Expand All @@ -50,6 +53,9 @@ jobs:
- name: Install dependencies
run: cd client && npm ci

- name: Lint with ESLint
run: cd client && npm run lint

- name: Run tests
run: cd client && npm test

Expand All @@ -70,6 +76,9 @@ jobs:
- name: Install dependencies
run: pip install -r gen-ai/requirements-dev.txt

- name: Lint with Ruff
run: ruff check gen-ai

- name: Run tests with pytest
run: pytest gen-ai

Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,22 @@ not rerun the Java tests.

---

### Linting / Static Analysis

Every service is linted in CI (`Test, Build and Push Images`) as a **blocking gate** —
a lint failure fails the build and prevents images from being pushed or deployed.

| Service | Tool | Run locally |
|---------|------|-------------|
| `client` | ESLint | `cd client && npm run lint` |
| `gen-ai` | [Ruff](https://docs.astral.sh/ruff/) | `cd gen-ai && ruff check .` |
| `api-gateway`, `user-service`, `grocery-service` | [Spotless](https://github.com/diffplug/spotless) (google-java-format) | `cd server/<service> && ./mvnw spotless:check` |

For the Java services, auto-format any violations with `./mvnw spotless:apply`.
Ruff config lives in `gen-ai/pyproject.toml`; ESLint config in `client/eslint.config.js`.

---

### Docker

Requires Docker Desktop running.
Expand Down
12 changes: 12 additions & 0 deletions gen-ai/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[tool.ruff]
target-version = "py312"
line-length = 100

[tool.ruff.lint]
# E = pycodestyle errors, F = pyflakes, I = import sorting.
# Kept intentionally small so it flags real problems (unused imports, undefined
# names, import order, obvious style errors) without drowning the service in noise.
select = ["E", "F", "I"]
# E501 (line-too-long) is not enforced: the LLM system prompts are long, intentional
# string literals where wrapping would change the text actually sent to the model.
ignore = ["E501"]
1 change: 1 addition & 0 deletions gen-ai/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
pytest
pytest-cov
httpx
ruff
1 change: 0 additions & 1 deletion gen-ai/tests/test_endpoint_merge.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json

from main import LOGOS_BASE_URL, LOGOS_MODEL, NO_LLM_NOTE

from tests.conftest import _fake_response


Expand Down
1 change: 0 additions & 1 deletion gen-ai/tests/test_endpoint_parse.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json

from main import CANNED_INGREDIENTS, LOGOS_BASE_URL, LOGOS_MODEL, NO_LLM_NOTE, OPENAI_MODEL

from tests.conftest import _fake_response


Expand Down
10 changes: 10 additions & 0 deletions server/api-gateway/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@
<argLine>-Djdk.attach.allowAttachSelf=true</argLine>
</configuration>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.44.5</version>
<configuration>
<java>
<googleJavaFormat/>
</java>
</configuration>
</plugin>
</plugins>
</build>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package com.bytebite.server;

import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
Expand All @@ -11,124 +18,118 @@
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {
private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder();
private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder();
private static final Base64.Encoder BASE64_URL_ENCODER = Base64.getUrlEncoder().withoutPadding();

private final byte[] secret;
private final byte[] secret;

public JwtAuthenticationFilter(@Value("${auth.jwt.secret}") String secret) {
if (secret == null || secret.length() < 32) {
throw new IllegalStateException("JWT secret must be at least 32 characters.");
}
this.secret = secret.getBytes(StandardCharsets.UTF_8);
public JwtAuthenticationFilter(@Value("${auth.jwt.secret}") String secret) {
if (secret == null || secret.length() < 32) {
throw new IllegalStateException("JWT secret must be at least 32 characters.");
}
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (!path.startsWith("/api/") || path.startsWith("/api/auth/")) {
return chain.filter(exchange);
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (!path.startsWith("/api/") || path.startsWith("/api/auth/")) {
return chain.filter(exchange);
}

String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
JwtPayload payload = verify(authorization);
if (payload == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
JwtPayload payload = verify(authorization);
if (payload == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}

// Use set() so any client-supplied X-User-* headers are overwritten, never trusted.
ServerHttpRequest request = exchange.getRequest().mutate()
.headers(headers -> {
headers.set("X-User-Id", payload.userId());
headers.set("X-User-Email", payload.email());
// Use set() so any client-supplied X-User-* headers are overwritten, never trusted.
ServerHttpRequest request =
exchange
.getRequest()
.mutate()
.headers(
headers -> {
headers.set("X-User-Id", payload.userId());
headers.set("X-User-Email", payload.email());
})
.build();
return chain.filter(exchange.mutate().request(request).build());
.build();
return chain.filter(exchange.mutate().request(request).build());
}

@Override
public int getOrder() {
return -100;
}

private JwtPayload verify(String authorizationHeader) {
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
return null;
}

@Override
public int getOrder() {
return -100;
String token = authorizationHeader.substring("Bearer ".length()).trim();
String[] parts = token.split("\\.");
if (parts.length != 3) {
return null;
}

private JwtPayload verify(String authorizationHeader) {
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
return null;
}
String token = authorizationHeader.substring("Bearer ".length()).trim();
String[] parts = token.split("\\.");
if (parts.length != 3) {
return null;
}
String unsigned = parts[0] + "." + parts[1];
if (!constantTimeEquals(sign(unsigned), parts[2])) {
return null;
}

Map<String, String> payload = parseFlatJson(new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8));
long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0"));
if (expiresAt <= Instant.now().getEpochSecond()) {
return null;
}
return new JwtPayload(payload.get("sub"), payload.get("email"));
String unsigned = parts[0] + "." + parts[1];
if (!constantTimeEquals(sign(unsigned), parts[2])) {
return null;
}

private String sign(String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("Could not verify JWT.", exception);
}
Map<String, String> payload =
parseFlatJson(new String(BASE64_URL_DECODER.decode(parts[1]), StandardCharsets.UTF_8));
long expiresAt = Long.parseLong(payload.getOrDefault("exp", "0"));
if (expiresAt <= Instant.now().getEpochSecond()) {
return null;
}

private boolean constantTimeEquals(String expected, String actual) {
byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8);
if (expectedBytes.length != actualBytes.length) {
return false;
}
int result = 0;
for (int i = 0; i < expectedBytes.length; i++) {
result |= expectedBytes[i] ^ actualBytes[i];
}
return result == 0;
return new JwtPayload(payload.get("sub"), payload.get("email"));
}

private String sign(String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return BASE64_URL_ENCODER.encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("Could not verify JWT.", exception);
}
}

private Map<String, String> parseFlatJson(String json) {
Map<String, String> values = new LinkedHashMap<>();
String body = json.substring(1, json.length() - 1);
for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) {
String[] parts = pair.split(":", 2);
if (parts.length == 2) {
values.put(unquote(parts[0]), unquote(parts[1]));
}
}
return values;
private boolean constantTimeEquals(String expected, String actual) {
byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8);
if (expectedBytes.length != actualBytes.length) {
return false;
}

private String unquote(String value) {
String trimmed = value.trim();
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
return trimmed.substring(1, trimmed.length() - 1)
.replace("\\\"", "\"")
.replace("\\\\", "\\");
}
return trimmed;
int result = 0;
for (int i = 0; i < expectedBytes.length; i++) {
result |= expectedBytes[i] ^ actualBytes[i];
}
return result == 0;
}

private Map<String, String> parseFlatJson(String json) {
Map<String, String> values = new LinkedHashMap<>();
String body = json.substring(1, json.length() - 1);
for (String pair : body.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)")) {
String[] parts = pair.split(":", 2);
if (parts.length == 2) {
values.put(unquote(parts[0]), unquote(parts[1]));
}
}
return values;
}

private record JwtPayload(String userId, String email) {
private String unquote(String value) {
String trimmed = value.trim();
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
return trimmed.substring(1, trimmed.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\");
}
return trimmed;
}

private record JwtPayload(String userId, String email) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
@SpringBootApplication
public class ServerApplication {

public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}

public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
}
Loading
Loading