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
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copy to .env and fill in. .env is gitignored; never commit real values.

# At least 32 characters. Without it the backend generates a throwaway key per start-up and
# everyone is signed out on every restart.
LIBRARY_JWT_SECRET=

# The bootstrap administrator's password. Blank means no administrator is created at all.
LIBRARY_ADMIN_PASSWORD=

# Origins allowed to call the API from a browser, comma separated. Leave blank unless the frontend
# is hosted separately, e.g. https://your-name.github.io
LIBRARY_CORS_ORIGINS=

# MySQL, for Notification-Service.
MYSQL_ROOT_PASSWORD=
MYSQL_USER=library
MYSQL_PASSWORD=

# Outbound email. Leave NOTIFICATION_MAIL_ENABLED false and notifications are stored as PENDING
# rather than sent, which is what you want unless you have a real mailbox.
NOTIFICATION_MAIL_ENABLED=false
NOTIFICATION_MAIL_USERNAME=
NOTIFICATION_MAIL_PASSWORD=
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,27 @@ jobs:
working-directory: frontend
run: npm ci

# With no backend to call, the app runs its own in the browser rather than publishing a site
# where nothing works. Set API_BASE_URL and it talks to the real one instead.
- name: Note whether an API origin is configured
id: api
run: |
if [ -z "${{ vars.API_BASE_URL }}" ]; then
echo "::notice::No API_BASE_URL, so the site is published in demo mode: the app answers"
echo "::notice::its own requests in the browser and every screen works, per visitor."
echo "::notice::Set API_BASE_URL to a backend origin to use the real one instead."
echo "unconfigured=true" >> "$GITHUB_OUTPUT"
else
echo "unconfigured=false" >> "$GITHUB_OUTPUT"
fi

- name: Build
working-directory: frontend
env:
# The site is served from https://<owner>.github.io/<repo>/, so assets need that prefix.
VITE_BASE_PATH: /${{ github.event.repository.name }}/
VITE_API_BASE_URL: ${{ vars.API_BASE_URL }}
VITE_DEMO: ${{ steps.api.outputs.unconfigured }}
run: npm run build

- name: Add an SPA fallback
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ replay_pid*.log

# Logs
*.log

# Local secrets
.env

# Local H2 database file
data/
9 changes: 9 additions & 0 deletions Analytics-Service/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Keep the build context to source. Anything else is either useless in an image or a leak.
target/
.mvn/wrapper/maven-wrapper.jar
*.log
.env
.env.*
.idea/
*.iml
README.md
35 changes: 35 additions & 0 deletions Analytics-Service/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ---- build ------------------------------------------------------------------------------------
# The JDK, Maven and the source tree stay in this stage; none of it reaches the image that runs.
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build

# Dependencies first, so a source-only change does not re-download the world.
COPY pom.xml .
RUN mvn -B -q dependency:go-offline

COPY src ./src
# Checkstyle, PMD and the tests run in CI; repeating them here only makes images slow to build.
RUN mvn -B -q clean package -DskipTests -Dcheckstyle.skip=true -Dpmd.skip=true \
&& mv target/*.jar /build/app.jar

# ---- run --------------------------------------------------------------------------------------
FROM eclipse-temurin:21-jre-alpine AS runtime

# A JRE, not a JDK: no compiler, no jar tool, nothing to build with if someone gets a shell.
RUN addgroup -S library && adduser -S -G library -H -s /sbin/nologin library

WORKDIR /app
COPY --from=build --chown=root:root --chmod=444 /build/app.jar /app/app.jar

# Owned by root and read-only to the account that runs it: the process cannot rewrite its own jar.
USER library

EXPOSE 9095

# Container memory, not the host's, and fail fast rather than swapping.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:9095/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "/app/app.jar"]
7 changes: 6 additions & 1 deletion Analytics-Service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- For the container healthcheck. Only /actuator/health is exposed; see the properties. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
Expand Down
12 changes: 11 additions & 1 deletion Analytics-Service/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@ spring.kafka.bootstrap-servers=localhost:9094
spring.kafka.consumer.group-id=analytics-service
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
# Wrapped in an ErrorHandlingDeserializer: without it a single unreadable record wedges the
# consumer, which retries the same offset forever and never sees anything after it.
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
spring.kafka.consumer.properties.spring.deserializer.value.delegate.class=org.springframework.kafka.support.serializer.JsonDeserializer
# Read every record as our own LoanEvent rather than whatever class the producer names. A type
# header from another service points at a class that does not exist here.
spring.kafka.consumer.properties.spring.json.use.type.headers=false
spring.kafka.consumer.properties.spring.json.trusted.packages=*
spring.kafka.consumer.properties.spring.json.value.default.type=springboot.analytics.event.LoanEvent
# Without a broker the container would otherwise log a stack trace every few seconds.
spring.kafka.listener.missing-topics-fatal=false

library.events.topic=library.loans

# Only the health endpoint, and without the detail that describes the innards to a stranger.
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package springboot.analytics;

import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.junit.jupiter.api.Test;
import org.springframework.kafka.support.serializer.JsonDeserializer;
import springboot.analytics.event.LoanEvent;

import java.nio.charset.StandardCharsets;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;

/**
* Reading what the library actually puts on the topic.
*
* <p>{@link LoanStatisticsServiceTest} hands events to the service directly, so it never exercises
* deserialization - and that is where the two services can disagree without any test noticing.
*/
class LoanEventDeserializationTest {

/** A borrow as the library's JsonSerializer writes it. */
private static final String PUBLISHED_JSON = """
{"type":"BOOK_BORROWED",
"customerId":"3f1a5f6e-7c2b-4a91-9d3e-5b8c1a2d4e6f",
"customerName":"Ada Lovelace",
"bookId":"9c8b7a6d-5e4f-4321-8a9b-0c1d2e3f4a5b",
"bookTitle":"Dune",
"bookIsbn":"978-0-441-01359-3",
"occurredAt":"2026-08-19T10:15:30Z"}""";

private static JsonDeserializer<LoanEvent> configuredAsTheConsumerIs() {
JsonDeserializer<LoanEvent> deserializer = new JsonDeserializer<>(LoanEvent.class);
deserializer.setUseTypeHeaders(false);
return deserializer;
}

@Test
void readsTheEventTheLibraryPublishes() {
try (JsonDeserializer<LoanEvent> deserializer = configuredAsTheConsumerIs()) {
LoanEvent event = deserializer.deserialize(
"library.loans", PUBLISHED_JSON.getBytes(StandardCharsets.UTF_8));

assertThat(event.type()).isEqualTo(LoanEvent.BORROWED);
assertThat(event.bookTitle()).isEqualTo("Dune");
assertThat(event.customerName()).isEqualTo("Ada Lovelace");
assertThat(event.occurredAt()).isNotNull();
}
}

/**
* Regression: the producer used to stamp __TypeId__ with its own class name, and honouring it
* threw ClassNotFoundException here - which wedged the consumer on the offending offset and
* stopped every later event. Only a running broker showed it; this makes it a unit test.
*/
@Test
void ignoresATypeHeaderNamingAClassThisServiceDoesNotHave() {
Headers headers = new RecordHeaders();
headers.add("__TypeId__", "app.adapters.output.events.LoanEvent".getBytes(StandardCharsets.UTF_8));

try (JsonDeserializer<LoanEvent> deserializer = configuredAsTheConsumerIs()) {
assertThatCode(() -> {
LoanEvent event = deserializer.deserialize(
"library.loans", headers, PUBLISHED_JSON.getBytes(StandardCharsets.UTF_8));
assertThat(event.bookTitle()).isEqualTo("Dune");
}).doesNotThrowAnyException();
}
}

/** Unknown fields must not break the consumer when the library adds one. */
@Test
void toleratesAFieldItDoesNotKnow() {
String withExtra = PUBLISHED_JSON.replace("\"type\":", "\"somethingNew\":\"x\",\"type\":");

try (JsonDeserializer<LoanEvent> deserializer = configuredAsTheConsumerIs()) {
assertThatCode(() -> deserializer.deserialize(
"library.loans", withExtra.getBytes(StandardCharsets.UTF_8)))
.doesNotThrowAnyException();
}
}
}
9 changes: 9 additions & 0 deletions Library-Management-System-Version-2/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Keep the build context to source. Anything else is either useless in an image or a leak.
target/
.mvn/wrapper/maven-wrapper.jar
*.log
.env
.env.*
.idea/
*.iml
README.md
40 changes: 40 additions & 0 deletions Library-Management-System-Version-2/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# ---- build ------------------------------------------------------------------------------------
# The JDK, Maven and the source tree stay in this stage; none of it reaches the image that runs.
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build

# Dependencies first, so a source-only change does not re-download the world.
COPY pom.xml .
RUN mvn -B -q dependency:go-offline

COPY src ./src
# Checkstyle, PMD and the tests run in CI; repeating them here only makes images slow to build.
RUN mvn -B -q clean package -DskipTests -Dcheckstyle.skip=true -Dpmd.skip=true \
&& mv target/*.jar /build/app.jar

# ---- run --------------------------------------------------------------------------------------
FROM eclipse-temurin:21-jre-alpine AS runtime

# A JRE, not a JDK: no compiler, no jar tool, nothing to build with if someone gets a shell.
RUN addgroup -S library && adduser -S -G library -H -s /sbin/nologin library

WORKDIR /app
COPY --from=build --chown=root:root --chmod=444 /build/app.jar /app/app.jar

# The H2 file lives here. Created now and owned by the app user so that a named volume mounted over
# it inherits that ownership - otherwise the volume arrives root-owned and the process cannot write.
RUN mkdir -p /app/data && chown library:library /app/data
VOLUME ["/app/data"]

# Owned by root and read-only to the account that runs it: the process cannot rewrite its own jar.
USER library

EXPOSE 9092

# Container memory, not the host's, and fail fast rather than swapping.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:9092/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.beans.factory.annotation.Value;

import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
Expand All @@ -43,14 +51,53 @@ public class SecurityConfig {
"/api/**", "/admin/**", "/books/**", "/authors/**", "/customers/**", "/transactions/**"
};

@Value("${library.cors.allowed-origins:}")
private String allowedOrigins;

private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationFilter authenticationFilter;

/**
* Cross-origin rules for the API, from {@code library.cors.allowed-origins}.
*
* <p>Empty by default, which allows nothing: locally the frontend is proxied and so is already
* same-origin. A separately hosted frontend has to be named here or the browser blocks it.
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

List<String> origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(origin -> !origin.isEmpty())
.toList();

if (origins.isEmpty()) {
return source;
}

CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(origins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept"));
// The pagination links are custom headers, and a browser hides those from cross-origin
// JavaScript unless they are named here.
config.setExposedHeaders(List.of("Authorization", "self", "next", "prev"));
// The token travels in a header, not a cookie, so credentials are not needed - and leaving
// them off is what allows an explicit origin list to stay strict.
config.setAllowCredentials(false);
config.setMaxAge(Duration.ofHours(1));

source.registerCorsConfiguration("/**", config);
return source;
}

@Bean
@Order(1)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher(API_PATHS)
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/login", "/api/register").permitAll()
Expand Down Expand Up @@ -88,6 +135,8 @@ public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exce
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/error", "/favicon.ico").permitAll()
.requestMatchers("/index.html", "/css/**", "/js/**", "/images/**").permitAll()
// Only where the console exists at all - see application.properties.
// Reachable without authentication, so it must never be on in a deployment.
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Local development only.

# The H2 console, which the default configuration deliberately leaves off: it needs no
# authentication, so it belongs on a laptop and nowhere else.
spring.h2.console.enabled=true
spring.h2.console.settings.web-allow-others=true

# Local credentials. These live here rather than in application.properties so that a deployment
# which forgets to set them gets no administrator and a throwaway signing key, instead of the
# well-known values from a public repository.
library.admin.password=admin
library.jwt.secret=local-development-only-signing-key-change-me

# In-memory locally: a fresh database each run is what makes the JSON fixture reproducible, and
# DatabaseSeeder would otherwise add another copy of it on every start.
spring.datasource.url=jdbc:h2:mem:library_ms
Loading
Loading