From 9db919e1105975ddc040cccac1598f6eec2f95c7 Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Wed, 19 Aug 2026 15:22:52 +0200 Subject: [PATCH 1/9] feat: allow a separately hosted frontend through configurable CORS origins --- .../config/security/SecurityConfig.java | 49 +++++++++++++++++++ .../main/resources/application-dev.properties | 6 +++ .../src/main/resources/application.properties | 12 +++-- 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 Library-Management-System-Version-2/src/main/resources/application-dev.properties diff --git a/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/security/SecurityConfig.java b/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/security/SecurityConfig.java index 5a83a34..982736e 100644 --- a/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/security/SecurityConfig.java +++ b/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/security/SecurityConfig.java @@ -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; @@ -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}. + * + *

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 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() @@ -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() diff --git a/Library-Management-System-Version-2/src/main/resources/application-dev.properties b/Library-Management-System-Version-2/src/main/resources/application-dev.properties new file mode 100644 index 0000000..8cbd835 --- /dev/null +++ b/Library-Management-System-Version-2/src/main/resources/application-dev.properties @@ -0,0 +1,6 @@ +# 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 diff --git a/Library-Management-System-Version-2/src/main/resources/application.properties b/Library-Management-System-Version-2/src/main/resources/application.properties index b83b649..483d26f 100644 --- a/Library-Management-System-Version-2/src/main/resources/application.properties +++ b/Library-Management-System-Version-2/src/main/resources/application.properties @@ -18,10 +18,11 @@ spring.jpa.open-in-view=false spring.datasource.username=root spring.datasource.password=12345 -# Enable H2 console -spring.h2.console.enabled=true +# H2 console. Off by default: it answers without authentication and web-allow-others lets any host +# reach it, which together is a database console open to the internet on anything deployed. +# The dev profile turns it back on - see application-dev.properties. +spring.h2.console.enabled=false spring.h2.console.path=/h2-console -spring.h2.console.settings.web-allow-others=true # Caching. Set explicitly to in-memory: spring-boot-starter-data-redis is on the classpath, so # Boot would otherwise pick Redis and every cached call would fail against a server that is not @@ -67,6 +68,11 @@ spring.cloud.openfeign.client.config.notification-service.readTimeout=3000 # Override by environment anywhere real - a secret in source is a secret anyone can mint tokens with. library.jwt.secret=${LIBRARY_JWT_SECRET:local-development-only-signing-key-change-me} +# Origins allowed to call this API from a browser. Empty means none, which is right locally where +# the frontend is proxied and already same-origin. A separately hosted frontend - GitHub Pages, say - +# must be named here, comma separated, or the browser blocks every call. +library.cors.allowed-origins=${LIBRARY_CORS_ORIGINS:} + # Analytics-Service integration. Read-only: this application publishes loan events and never owns # the totals, so the statistics are a projection it borrows back for the Insights page. analytics.service.url=http://localhost:9095/api/v1/analytics From 13d2fd2ef0c8284fa64d8f898b0681e9e8c12933 Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Wed, 19 Aug 2026 15:22:53 +0200 Subject: [PATCH 2/9] test: cover the CORS preflight, and that no origin is allowed by default --- .../input/CorsConfigurationTestIT.java | 60 +++++++++++++++++++ .../input/CorsDisabledByDefaultTestIT.java | 30 ++++++++++ 2 files changed, 90 insertions(+) create mode 100644 Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsConfigurationTestIT.java create mode 100644 Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsDisabledByDefaultTestIT.java diff --git a/Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsConfigurationTestIT.java b/Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsConfigurationTestIT.java new file mode 100644 index 0000000..2b77d21 --- /dev/null +++ b/Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsConfigurationTestIT.java @@ -0,0 +1,60 @@ +package app.adapters.input; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** A separately hosted frontend only works if the preflight is answered for its origin. */ +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = "library.cors.allowed-origins=https://example.github.io") +@Tag("integration") +class CorsConfigurationTestIT { + + private static final String ALLOWED = "https://example.github.io"; + + @Autowired + private MockMvc mockMvc; + + @Test + void allowsThePreflightFromAConfiguredOrigin() throws Exception { + mockMvc.perform(options("/api/login") + .header(HttpHeaders.ORIGIN, ALLOWED) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "content-type")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED)); + } + + /** + * The token travels in this header, so a browser will not send it unless it is allowed. + * Spring echoes the requested name verbatim, hence the case-insensitive match. + */ + @Test + void allowsTheAuthorizationHeader() throws Exception { + mockMvc.perform(options("/books/paginated") + .header(HttpHeaders.ORIGIN, ALLOWED) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, + org.hamcrest.Matchers.containsStringIgnoringCase("authorization"))); + } + + @Test + void refusesAnOriginThatIsNotConfigured() throws Exception { + mockMvc.perform(options("/api/login") + .header(HttpHeaders.ORIGIN, "https://not-mine.example") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .andExpect(status().isForbidden()); + } +} diff --git a/Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsDisabledByDefaultTestIT.java b/Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsDisabledByDefaultTestIT.java new file mode 100644 index 0000000..e82a643 --- /dev/null +++ b/Library-Management-System-Version-2/src/test/java/app/adapters/input/CorsDisabledByDefaultTestIT.java @@ -0,0 +1,30 @@ +package app.adapters.input; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; + +/** With no origins configured, nothing is allowed cross-origin. */ +@SpringBootTest +@AutoConfigureMockMvc +@Tag("integration") +class CorsDisabledByDefaultTestIT { + + @Autowired + private MockMvc mockMvc; + + @Test + void doesNotAllowAnArbitraryOrigin() throws Exception { + mockMvc.perform(options("/api/login") + .header(HttpHeaders.ORIGIN, "https://somewhere.example") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + } +} From 18ddc04a111b311777df53dc65d718271fad17fc Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Wed, 19 Aug 2026 15:22:53 +0200 Subject: [PATCH 3/9] ci: fail the Pages build when no API origin is configured --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3274758..ebbbae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,18 @@ jobs: working-directory: frontend run: npm ci + # Without this the build succeeds and publishes a site whose every request goes back to + # GitHub Pages, which serves static files only and answers 405 to a login. Failing here says + # what is wrong; the deployed site could only say "that request could not be completed". + - name: Require an API origin + if: vars.API_BASE_URL == '' + run: | + echo "::error::API_BASE_URL is not set, so the published site would have no backend to talk to." + echo "Set it under Settings > Secrets and variables > Actions > Variables to the origin" + echo "of a publicly reachable, HTTPS backend - and add that origin to LIBRARY_CORS_ORIGINS" + echo "on the backend, or the browser will block every call." + exit 1 + - name: Build working-directory: frontend env: From 3ab573e17ab2268da28bea62a9a4338421865f8d Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Wed, 19 Aug 2026 15:36:51 +0200 Subject: [PATCH 4/9] feat: add Docker images and a compose stack, with no credentials in source --- .env.example | 23 +++ .gitignore | 3 + Analytics-Service/.dockerignore | 9 ++ Analytics-Service/Dockerfile | 35 +++++ Analytics-Service/pom.xml | 7 +- .../src/main/resources/application.properties | 4 + .../.dockerignore | 9 ++ .../Dockerfile | 35 +++++ .../main/resources/application-dev.properties | 6 + .../src/main/resources/application.properties | 17 ++- .../Notification-Service/.dockerignore | 9 ++ .../Notification-Service/Dockerfile | 35 +++++ .../Notification-Service/pom.xml | 7 +- .../src/main/resources/application.properties | 14 +- README.md | 26 ++++ docker-compose.yml | 140 ++++++++++++++++++ 16 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 .env.example create mode 100644 Analytics-Service/.dockerignore create mode 100644 Analytics-Service/Dockerfile create mode 100644 Library-Management-System-Version-2/.dockerignore create mode 100644 Library-Management-System-Version-2/Dockerfile create mode 100644 Notification-Service/Notification-Service/.dockerignore create mode 100644 Notification-Service/Notification-Service/Dockerfile create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5082c43 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 7a7f4b0..0a478b6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ replay_pid*.log # Logs *.log + +# Local secrets +.env diff --git a/Analytics-Service/.dockerignore b/Analytics-Service/.dockerignore new file mode 100644 index 0000000..2f723d1 --- /dev/null +++ b/Analytics-Service/.dockerignore @@ -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 diff --git a/Analytics-Service/Dockerfile b/Analytics-Service/Dockerfile new file mode 100644 index 0000000..aeb4179 --- /dev/null +++ b/Analytics-Service/Dockerfile @@ -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"] diff --git a/Analytics-Service/pom.xml b/Analytics-Service/pom.xml index 63a2c92..e3071a9 100644 --- a/Analytics-Service/pom.xml +++ b/Analytics-Service/pom.xml @@ -51,7 +51,12 @@ spring-boot-starter-test test - + + + org.springframework.boot + spring-boot-starter-actuator + + diff --git a/Analytics-Service/src/main/resources/application.properties b/Analytics-Service/src/main/resources/application.properties index 471c36c..7e21ff4 100644 --- a/Analytics-Service/src/main/resources/application.properties +++ b/Analytics-Service/src/main/resources/application.properties @@ -23,3 +23,7 @@ spring.kafka.consumer.properties.spring.json.value.default.type=springboot.analy 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 diff --git a/Library-Management-System-Version-2/.dockerignore b/Library-Management-System-Version-2/.dockerignore new file mode 100644 index 0000000..2f723d1 --- /dev/null +++ b/Library-Management-System-Version-2/.dockerignore @@ -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 diff --git a/Library-Management-System-Version-2/Dockerfile b/Library-Management-System-Version-2/Dockerfile new file mode 100644 index 0000000..3396af4 --- /dev/null +++ b/Library-Management-System-Version-2/Dockerfile @@ -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 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"] diff --git a/Library-Management-System-Version-2/src/main/resources/application-dev.properties b/Library-Management-System-Version-2/src/main/resources/application-dev.properties index 8cbd835..f3d0a28 100644 --- a/Library-Management-System-Version-2/src/main/resources/application-dev.properties +++ b/Library-Management-System-Version-2/src/main/resources/application-dev.properties @@ -4,3 +4,9 @@ # 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 diff --git a/Library-Management-System-Version-2/src/main/resources/application.properties b/Library-Management-System-Version-2/src/main/resources/application.properties index 483d26f..98d640a 100644 --- a/Library-Management-System-Version-2/src/main/resources/application.properties +++ b/Library-Management-System-Version-2/src/main/resources/application.properties @@ -66,7 +66,10 @@ spring.cloud.openfeign.client.config.notification-service.readTimeout=3000 # Signing key for JWTs. Set this and sessions survive a restart; leave it blank and a fresh key is # generated per start-up, signing everyone out on every devtools reload. Minimum 32 characters. # Override by environment anywhere real - a secret in source is a secret anyone can mint tokens with. -library.jwt.secret=${LIBRARY_JWT_SECRET:local-development-only-signing-key-change-me} +# No default on purpose. A key committed here is a key anyone with the repository can sign tokens +# with, so blank is safer: JwtService then generates one per start-up, and a restart simply signs +# people out. The dev profile sets a fixed local key; deployments must set LIBRARY_JWT_SECRET. +library.jwt.secret=${LIBRARY_JWT_SECRET:} # Origins allowed to call this API from a browser. Empty means none, which is right locally where # the frontend is proxied and already same-origin. A separately hosted frontend - GitHub Pages, say - @@ -111,7 +114,9 @@ spring.security.user.password={password} # account the administrator screens have no way in. Override the password by environment in # anything that is not a local run. library.admin.username=admin -library.admin.password=${LIBRARY_ADMIN_PASSWORD:admin} +# Also no default. Blank means DataInitializer creates no administrator at all and says so, which +# is the safe failure: better to have no way in than admin/admin reachable from the internet. +library.admin.password=${LIBRARY_ADMIN_PASSWORD:} # Redis configuration #spring.cache.type=redis @@ -119,8 +124,6 @@ library.admin.password=${LIBRARY_ADMIN_PASSWORD:admin} #spring.data.redis.port=6379 #spring.cache.cache-names=libraryCache - - - - - +# 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 diff --git a/Notification-Service/Notification-Service/.dockerignore b/Notification-Service/Notification-Service/.dockerignore new file mode 100644 index 0000000..2f723d1 --- /dev/null +++ b/Notification-Service/Notification-Service/.dockerignore @@ -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 diff --git a/Notification-Service/Notification-Service/Dockerfile b/Notification-Service/Notification-Service/Dockerfile new file mode 100644 index 0000000..2bdedb6 --- /dev/null +++ b/Notification-Service/Notification-Service/Dockerfile @@ -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 9093 + +# 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:9093/actuator/health || exit 1 + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/Notification-Service/Notification-Service/pom.xml b/Notification-Service/Notification-Service/pom.xml index dcf59a4..8084218 100644 --- a/Notification-Service/Notification-Service/pom.xml +++ b/Notification-Service/Notification-Service/pom.xml @@ -62,7 +62,12 @@ spring-boot-starter-test test - + + + org.springframework.boot + spring-boot-starter-actuator + + diff --git a/Notification-Service/Notification-Service/src/main/resources/application.properties b/Notification-Service/Notification-Service/src/main/resources/application.properties index 53adacc..639f80e 100644 --- a/Notification-Service/Notification-Service/src/main/resources/application.properties +++ b/Notification-Service/Notification-Service/src/main/resources/application.properties @@ -4,8 +4,8 @@ server.port=9093 # MySQL database configuration spring.datasource.url=jdbc:mysql://localhost:3306/notification_service?createDatabaseIfNotExist=true -spring.datasource.username=root -spring.datasource.password=12345 +spring.datasource.username=${NOTIFICATION_DB_USERNAME:root} +spring.datasource.password=${NOTIFICATION_DB_PASSWORD:} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true @@ -16,12 +16,16 @@ logging.level.org.hibernate.persister.entity=ERROR # Email Sender Configuration spring.mail.host=smtp.gmail.com spring.mail.port=587 -spring.mail.username= -spring.mail.password= +spring.mail.username=${NOTIFICATION_MAIL_USERNAME:} +spring.mail.password=${NOTIFICATION_MAIL_PASSWORD:} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true # Actual delivery is opt-in. While false, notifications are still persisted and returned # with status PENDING instead of every request failing against an unconfigured mailbox. # Set spring.mail.username/password above, then flip this to true to send real email. -notification.mail.enabled=false \ No newline at end of file +notification.mail.enabled=false + +# 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 diff --git a/README.md b/README.md index 6fb5318..da275ff 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,32 @@ killed Maven wrapper leaves its Java child running, still holding the port. The steps below are the same thing by hand. +## Running it in Docker + +```bash +cp .env.example .env # then fill it in - compose refuses to start with any secret missing +docker compose up --build +``` + +Only the backend publishes a port, and only on loopback (`127.0.0.1:9092`). MySQL, Kafka, +Notification-Service and Analytics-Service are reachable on the internal network and nowhere else, +because neither service authenticates its callers. + +| Variable | Required | Notes | +| --------------------- | -------- | ---------------------------------------------------------- | +| `LIBRARY_JWT_SECRET` | yes | 32+ characters; without it everyone is signed out on restart | +| `LIBRARY_ADMIN_PASSWORD` | yes | Blank creates no administrator at all | +| `MYSQL_ROOT_PASSWORD`, `MYSQL_USER`, `MYSQL_PASSWORD` | yes | For Notification-Service | +| `LIBRARY_CORS_ORIGINS` | no | Set to the frontend's origin when it is hosted separately | +| `NOTIFICATION_MAIL_*` | no | Mail stays off, and notifications are stored as `PENDING` | + +Put a TLS-terminating reverse proxy in front of 9092; the application speaks plain HTTP, and a +browser on an HTTPS page will not call an HTTP API. + +**The database is still in-memory.** A restart of the backend wipes the catalogue and every +self-registered member; the administrator is recreated from configuration. Fine for a demo, not for +anything you want to keep - switch to file-backed H2 on a volume, or Postgres/MySQL, first. + ## Prerequisites - JDK 21 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..af070cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,140 @@ +# The full stack. +# +# Only the library backend publishes a port. MySQL, Kafka and the two supporting services are +# reachable on the internal network and nowhere else, so nothing here is exposed to the host that +# does not have to be. +# +# Every secret is required rather than defaulted: `${VAR:?...}` stops compose before anything runs +# if it is missing, which is better than a container coming up with a value from a public +# repository. Copy .env.example to .env and fill it in. + +name: library + +x-hardening: &hardening + # No process in these containers ever needs to gain privileges it was not started with. + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + +services: + backend: + build: + context: ./Library-Management-System-Version-2 + <<: *hardening + # The only port on the host. Bound to localhost: put a TLS-terminating reverse proxy in front + # rather than exposing the application directly. + ports: + - "127.0.0.1:9092:9092" + environment: + # No profile: the dev profile turns on the H2 console, which answers without authentication. + LIBRARY_JWT_SECRET: ${LIBRARY_JWT_SECRET:?set a signing key of at least 32 characters in .env} + LIBRARY_ADMIN_PASSWORD: ${LIBRARY_ADMIN_PASSWORD:?set an administrator password in .env} + LIBRARY_CORS_ORIGINS: ${LIBRARY_CORS_ORIGINS:-} + SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + NOTIFICATION_SERVICE_URL: http://notification:9093/api/v1/notifications + ANALYTICS_SERVICE_URL: http://analytics:9095/api/v1/analytics + read_only: true + tmpfs: + # The JVM and Tomcat want somewhere to write; everything else stays read-only. + - /tmp + depends_on: + kafka: + condition: service_healthy + networks: [library] + + notification: + build: + context: ./Notification-Service/Notification-Service + <<: *hardening + # No ports: this service has no authentication of its own and trusts the userId it is given, + # so it must never be reachable from outside this network. + expose: ["9093"] + environment: + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/notification_service?createDatabaseIfNotExist=true + NOTIFICATION_DB_USERNAME: ${MYSQL_USER:?set the database user in .env} + NOTIFICATION_DB_PASSWORD: ${MYSQL_PASSWORD:?set the database password in .env} + NOTIFICATION_MAIL_USERNAME: ${NOTIFICATION_MAIL_USERNAME:-} + NOTIFICATION_MAIL_PASSWORD: ${NOTIFICATION_MAIL_PASSWORD:-} + NOTIFICATION_MAIL_ENABLED: ${NOTIFICATION_MAIL_ENABLED:-false} + read_only: true + tmpfs: + - /tmp + depends_on: + mysql: + condition: service_healthy + networks: [library] + + analytics: + build: + context: ./Analytics-Service + <<: *hardening + # Also unauthenticated: the backend proxies it behind its own admin check. + expose: ["9095"] + environment: + SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + read_only: true + tmpfs: + - /tmp + depends_on: + kafka: + condition: service_healthy + networks: [library] + + mysql: + image: mysql:8.4 + <<: *hardening + # Not published: only Notification-Service needs it. + expose: ["3306"] + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set a root password in .env} + MYSQL_DATABASE: notification_service + MYSQL_USER: ${MYSQL_USER:?set the database user in .env} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set the database password in .env} + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 40s + networks: [library] + + kafka: + image: apache/kafka:3.9.0 + <<: *hardening + # KRaft, so no ZooKeeper. Single node, which is right for one host and wrong for production. + expose: ["9092"] + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + CLUSTER_ID: ${KAFKA_CLUSTER_ID:-5L6g3nShT-eMCtK--X86sw} + volumes: + - kafka-data:/var/lib/kafka/data + healthcheck: + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"] + interval: 10s + timeout: 10s + retries: 12 + start_period: 30s + networks: [library] + +volumes: + mysql-data: + kafka-data: + +networks: + library: + driver: bridge From 01c0bd6c42aef03403bd4d6b00589ace80055052 Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Wed, 19 Aug 2026 15:57:29 +0200 Subject: [PATCH 5/9] fix: stop Kafka type headers breaking the analytics consumer --- .../src/main/resources/application.properties | 8 +++++++- .../src/main/resources/application.properties | 3 +++ docker-compose.yml | 9 ++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Analytics-Service/src/main/resources/application.properties b/Analytics-Service/src/main/resources/application.properties index 7e21ff4..4d72964 100644 --- a/Analytics-Service/src/main/resources/application.properties +++ b/Analytics-Service/src/main/resources/application.properties @@ -16,7 +16,13 @@ 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. diff --git a/Library-Management-System-Version-2/src/main/resources/application.properties b/Library-Management-System-Version-2/src/main/resources/application.properties index 98d640a..a15e58c 100644 --- a/Library-Management-System-Version-2/src/main/resources/application.properties +++ b/Library-Management-System-Version-2/src/main/resources/application.properties @@ -39,6 +39,9 @@ management.health.redis.enabled=false spring.kafka.bootstrap-servers=localhost:9094 spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer +# No __TypeId__ header. It carries this application's class name, which the consumer then tries to +# load and cannot - the topic is the contract, not our package layout. +spring.kafka.producer.properties.spring.json.add.type.headers=false spring.kafka.producer.properties.max.block.ms=2000 library.events.topic=library.loans # Set to false to stop publishing entirely; borrowing works either way. diff --git a/docker-compose.yml b/docker-compose.yml index af070cc..9b7e27e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,6 +85,13 @@ services: mysql: image: mysql:8.4 <<: *hardening + # The entrypoint starts as root and drops to the mysql user, which needs these four back. + # Still far tighter than the default set, but ALL on its own makes mysqld abort on setgid. + cap_add: + - CHOWN + - SETGID + - SETUID + - DAC_OVERRIDE # Not published: only Notification-Service needs it. expose: ["3306"] environment: @@ -95,7 +102,7 @@ services: volumes: - mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"] interval: 10s timeout: 5s retries: 12 From 3c42573a52f2bdeb5bfbb1a9650d2f16f6b3746e Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Thu, 20 Aug 2026 00:00:13 +0200 Subject: [PATCH 6/9] test: read the event the library publishes, ignoring a foreign type header --- .../LoanEventDeserializationTest.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Analytics-Service/src/test/java/springboot/analytics/LoanEventDeserializationTest.java diff --git a/Analytics-Service/src/test/java/springboot/analytics/LoanEventDeserializationTest.java b/Analytics-Service/src/test/java/springboot/analytics/LoanEventDeserializationTest.java new file mode 100644 index 0000000..ab0cd45 --- /dev/null +++ b/Analytics-Service/src/test/java/springboot/analytics/LoanEventDeserializationTest.java @@ -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. + * + *

{@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 configuredAsTheConsumerIs() { + JsonDeserializer deserializer = new JsonDeserializer<>(LoanEvent.class); + deserializer.setUseTypeHeaders(false); + return deserializer; + } + + @Test + void readsTheEventTheLibraryPublishes() { + try (JsonDeserializer 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 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 deserializer = configuredAsTheConsumerIs()) { + assertThatCode(() -> deserializer.deserialize( + "library.loans", withExtra.getBytes(StandardCharsets.UTF_8))) + .doesNotThrowAnyException(); + } + } +} From 812247e7add807623f9e9b1fdb6ac0c1d45f37a9 Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Thu, 20 Aug 2026 00:16:20 +0200 Subject: [PATCH 7/9] feat: store the library in a file-backed H2 database so data survives a restart --- .github/workflows/ci.yml | 24 +++++++++++-------- .gitignore | 3 +++ .../Dockerfile | 5 ++++ .../main/resources/application-dev.properties | 4 ++++ .../src/main/resources/application.properties | 4 +++- .../src/test/resources/application.properties | 4 ++++ docker-compose.yml | 5 ++++ frontend/src/api/client.ts | 6 +++++ frontend/src/pages/LoginPage.tsx | 8 +++++++ 9 files changed, 52 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebbbae1..79424fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,17 +218,20 @@ jobs: working-directory: frontend run: npm ci - # Without this the build succeeds and publishes a site whose every request goes back to - # GitHub Pages, which serves static files only and answers 405 to a login. Failing here says - # what is wrong; the deployed site could only say "that request could not be completed". - - name: Require an API origin - if: vars.API_BASE_URL == '' + # Deploying without a backend is a legitimate thing to want - the UI is still worth looking + # at. What is not acceptable is a site that looks broken, so the build passes the fact down + # to the app, which then says so on the sign-in page instead of failing a login mysteriously. + - name: Note whether an API origin is configured + id: api run: | - echo "::error::API_BASE_URL is not set, so the published site would have no backend to talk to." - echo "Set it under Settings > Secrets and variables > Actions > Variables to the origin" - echo "of a publicly reachable, HTTPS backend - and add that origin to LIBRARY_CORS_ORIGINS" - echo "on the backend, or the browser will block every call." - exit 1 + if [ -z "${{ vars.API_BASE_URL }}" ]; then + echo "::warning::API_BASE_URL is not set. The site will publish, but it has no backend" + echo "::warning::to sign in against. Set it under Settings > Secrets and variables >" + echo "::warning::Actions > Variables, and add the same origin to LIBRARY_CORS_ORIGINS." + echo "unconfigured=true" >> "$GITHUB_OUTPUT" + else + echo "unconfigured=false" >> "$GITHUB_OUTPUT" + fi - name: Build working-directory: frontend @@ -236,6 +239,7 @@ jobs: # The site is served from https://.github.io//, so assets need that prefix. VITE_BASE_PATH: /${{ github.event.repository.name }}/ VITE_API_BASE_URL: ${{ vars.API_BASE_URL }} + VITE_BACKEND_UNCONFIGURED: ${{ steps.api.outputs.unconfigured }} run: npm run build - name: Add an SPA fallback diff --git a/.gitignore b/.gitignore index 0a478b6..ee74ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ replay_pid*.log # Local secrets .env + +# Local H2 database file +data/ diff --git a/Library-Management-System-Version-2/Dockerfile b/Library-Management-System-Version-2/Dockerfile index 3396af4..c997d7c 100644 --- a/Library-Management-System-Version-2/Dockerfile +++ b/Library-Management-System-Version-2/Dockerfile @@ -21,6 +21,11 @@ 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 diff --git a/Library-Management-System-Version-2/src/main/resources/application-dev.properties b/Library-Management-System-Version-2/src/main/resources/application-dev.properties index f3d0a28..3a092dd 100644 --- a/Library-Management-System-Version-2/src/main/resources/application-dev.properties +++ b/Library-Management-System-Version-2/src/main/resources/application-dev.properties @@ -10,3 +10,7 @@ spring.h2.console.settings.web-allow-others=true # 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 diff --git a/Library-Management-System-Version-2/src/main/resources/application.properties b/Library-Management-System-Version-2/src/main/resources/application.properties index a15e58c..415a11d 100644 --- a/Library-Management-System-Version-2/src/main/resources/application.properties +++ b/Library-Management-System-Version-2/src/main/resources/application.properties @@ -8,7 +8,9 @@ spring.application.name=library-ms server.port=9092 # H2 in-memory database configuration -spring.datasource.url=jdbc:h2:mem:library_ms +# On disk, so the catalogue and the members who registered survive a restart. The dev profile and +# the tests override this back to in-memory, where a throwaway database is the point. +spring.datasource.url=${LIBRARY_DB_URL:jdbc:h2:file:./data/library_ms} spring.datasource.driverClassName=org.h2.Driver spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update diff --git a/Library-Management-System-Version-2/src/test/resources/application.properties b/Library-Management-System-Version-2/src/test/resources/application.properties index dcaad91..def1b45 100644 --- a/Library-Management-System-Version-2/src/test/resources/application.properties +++ b/Library-Management-System-Version-2/src/test/resources/application.properties @@ -28,3 +28,7 @@ library.jwt.secret=integration-test-signing-key-not-a-secret # unnoticed for a long time because nothing in the tests ever touched the cache. spring.cache.type=simple management.health.redis.enabled=false + +# In-memory, stated here rather than inherited from the dev profile: a suite that writes a database +# file leaves it behind for the next run, and the tests assume they start from nothing. +spring.datasource.url=jdbc:h2:mem:library_ms diff --git a/docker-compose.yml b/docker-compose.yml index 9b7e27e..54cb01f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,10 +35,14 @@ services: SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092 NOTIFICATION_SERVICE_URL: http://notification:9093/api/v1/notifications ANALYTICS_SERVICE_URL: http://analytics:9095/api/v1/analytics + LIBRARY_DB_URL: jdbc:h2:file:/app/data/library_ms read_only: true tmpfs: # The JVM and Tomcat want somewhere to write; everything else stays read-only. - /tmp + volumes: + # The one writable path: the catalogue and its members outlive the container without it. + - backend-data:/app/data depends_on: kafka: condition: service_healthy @@ -139,6 +143,7 @@ services: networks: [library] volumes: + backend-data: mysql-data: kafka-data: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6803921..7b229fb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,6 +9,12 @@ import type { Page, Session } from '../types/domain' */ const BASE = import.meta.env.VITE_API_BASE_URL ?? '/backend' +/** + * True when the build knew it was going somewhere with no backend to talk to - a static host with + * no API origin configured. The UI says so rather than letting every sign-in fail unexplained. + */ +export const BACKEND_UNCONFIGURED = import.meta.env.VITE_BACKEND_UNCONFIGURED === 'true' + const TOKEN_KEY = 'library.jwt' const SESSION_KEY = 'library.session' diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index c9c96bd..6cb2ab4 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import type { FormEvent } from 'react' import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom' +import { BACKEND_UNCONFIGURED } from '../api/client' import { useAuth } from '../auth/AuthContext' import { BrandMark } from '../components/Layout' @@ -45,6 +46,13 @@ export function LoginPage() {

Welcome back

Sign in with your username and password.

+ {BACKEND_UNCONFIGURED && ( +

+ Preview only. This deployment has no backend configured, so signing in + and registering will not work. The pages themselves are here to look at. +

+ )} + {error && (

{error} From f2902a4b9fa97ee0f17a77c441483b6715eea7e5 Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Thu, 20 Aug 2026 00:16:47 +0200 Subject: [PATCH 8/9] docs: record that the database is now file-backed and the console is dev-only --- README.md | 9 ++++++--- docs/architecture/library-backend.md | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da275ff..77749b1 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,12 @@ because neither service authenticates its callers. Put a TLS-terminating reverse proxy in front of 9092; the application speaks plain HTTP, and a browser on an HTTPS page will not call an HTTP API. -**The database is still in-memory.** A restart of the backend wipes the catalogue and every -self-registered member; the administrator is recreated from configuration. Fine for a demo, not for -anything you want to keep - switch to file-backed H2 on a volume, or Postgres/MySQL, first. +The database is file-backed H2 on a named volume, so the catalogue and everyone who registered +survive a restart. `LIBRARY_DB_URL` moves it elsewhere. Locally, `dev.ps1` runs the dev profile, +which stays in memory - a throwaway database is what makes the JSON fixture reproducible. + +H2 suits one instance writing one file. Two backends against the same volume will not work; that is +the point at which to move to Postgres or MySQL. ## Prerequisites diff --git a/docs/architecture/library-backend.md b/docs/architecture/library-backend.md index 08dfa05..e240570 100644 --- a/docs/architecture/library-backend.md +++ b/docs/architecture/library-backend.md @@ -140,9 +140,12 @@ The two notes are the design: neither call can fail the borrow. ## Data -H2 in-memory, so everything is gone on restart. The console is at `/h2-console` -(`jdbc:h2:mem:library_ms`). The administrator is recreated at each start-up; self-registered members -are not. +H2, file-backed by default (`./data/library_ms`, or `LIBRARY_DB_URL`), so the catalogue and its +members outlive a restart. The `dev` profile and the tests override it to in-memory, where starting +from nothing is the point. + +The H2 console is off unless the `dev` profile is active: it answers without authentication, so it +belongs on a laptop and nowhere else. Seeding depends on the profile: From 4c4d503dcff0f2d1ae6b53443eb50a12d2a3834f Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Thu, 20 Aug 2026 08:50:50 +0200 Subject: [PATCH 9/9] feat: run the app against an in-browser backend when none is configured --- .github/workflows/ci.yml | 13 +- README.md | 11 +- frontend/README.md | 14 + frontend/src/api/client.ts | 40 ++- frontend/src/api/demo/backend.ts | 475 ++++++++++++++++++++++++++++ frontend/src/api/demo/store.ts | 138 ++++++++ frontend/src/pages/InsightsPage.tsx | 6 +- frontend/src/pages/LoginPage.tsx | 9 +- 8 files changed, 690 insertions(+), 16 deletions(-) create mode 100644 frontend/src/api/demo/backend.ts create mode 100644 frontend/src/api/demo/store.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79424fd..075b641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,16 +218,15 @@ jobs: working-directory: frontend run: npm ci - # Deploying without a backend is a legitimate thing to want - the UI is still worth looking - # at. What is not acceptable is a site that looks broken, so the build passes the fact down - # to the app, which then says so on the sign-in page instead of failing a login mysteriously. + # 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 "::warning::API_BASE_URL is not set. The site will publish, but it has no backend" - echo "::warning::to sign in against. Set it under Settings > Secrets and variables >" - echo "::warning::Actions > Variables, and add the same origin to LIBRARY_CORS_ORIGINS." + 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" @@ -239,7 +238,7 @@ jobs: # The site is served from https://.github.io//, so assets need that prefix. VITE_BASE_PATH: /${{ github.event.repository.name }}/ VITE_API_BASE_URL: ${{ vars.API_BASE_URL }} - VITE_BACKEND_UNCONFIGURED: ${{ steps.api.outputs.unconfigured }} + VITE_DEMO: ${{ steps.api.outputs.unconfigured }} run: npm run build - name: Add an SPA fallback diff --git a/README.md b/README.md index 77749b1..9e378ef 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,16 @@ Pushes to `main` publish the built frontend to **GitHub Pages** at `https://.github.io//`, after the Java and frontend jobs pass. Enable it once under **Settings → Pages → Source → GitHub Actions**. -Pages serves static files and nothing else, so **the API has to live somewhere else**: +**With no backend configured the site publishes in demo mode**: the app answers its own requests in +the browser, so a visitor can register, sign in, sign in as `admin` / `admin`, borrow, return and +use every admin screen. The data is theirs alone and lives in their browser. Nothing to host, and +nothing that looks broken. + +Set `API_BASE_URL` and it talks to the real backend instead; the demo code is then dropped from the +bundle entirely. + +Pages serves static files and nothing else, so **to use the real API it has to live somewhere +else**: | Repository variable | Effect | | ------------------- | ------ | diff --git a/frontend/README.md b/frontend/README.md index 76ebc24..a403825 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -156,6 +156,20 @@ than breaking. For an SPA, also copy `index.html` to `404.html`: a static host has no rewrite rule, so a deep link is a 404 until the same document is served for it. +## Demo mode + +Built with `VITE_DEMO=true`, the app answers its own requests from `src/api/demo/` instead of the +network: an in-memory library in `store.ts`, and the rules in `backend.ts` - a member borrows only +against their own membership, an administrator sees the admin screens, a book is out or it is not. +It exists so the published site works with nothing hosted behind it. + +`client.ts` routes there inside `request()`, so no page or service knows the difference, and the +same `ApiError`/`ForbiddenError`/`UnauthorizedError` come back either way. The import is dynamic +and the flag is static, so a normal build drops the whole thing. + +It is not the real backend: data is per-browser, the token is a username in a string, and Discover +returns invented results rather than searching Open Library. + ## Error messages Nothing in the UI shows an HTTP status code. `Request failed with status 502` is a fact about diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7b229fb..73d839a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -11,9 +11,10 @@ const BASE = import.meta.env.VITE_API_BASE_URL ?? '/backend' /** * True when the build knew it was going somewhere with no backend to talk to - a static host with - * no API origin configured. The UI says so rather than letting every sign-in fail unexplained. + * no API origin configured. Requests are then answered in the browser instead of over the network, + * so every screen works; the UI says where the data is coming from. */ -export const BACKEND_UNCONFIGURED = import.meta.env.VITE_BACKEND_UNCONFIGURED === 'true' +export const DEMO_MODE = import.meta.env.VITE_DEMO === 'true' const TOKEN_KEY = 'library.jwt' const SESSION_KEY = 'library.session' @@ -100,6 +101,10 @@ async function request(path: string, init: RequestInit = {}): Promise { headers.set('Authorization', token.startsWith('Bearer ') ? token : `Bearer ${token}`) } + if (DEMO_MODE) { + return demoRequest(path, init, headers.get('Authorization')) + } + let response: Response try { response = await fetch(`${BASE}${path}`, { ...init, headers }) @@ -170,6 +175,37 @@ function usableMessage(text: string): string | null { return trimmed } +/** + * Answers a request from the in-browser backend, raising the same errors the network path does - + * so `getPage`, the 401 redirect and every caller behave identically either way. + */ +async function demoRequest( + path: string, + init: RequestInit, + auth: string | null, +): Promise { + const { handle, DemoHttpError } = await import('./demo/backend') + const method = (init.method ?? 'GET').toUpperCase() + const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined + + // A little latency, so loading states are visible rather than flashing past. + await new Promise((resolve) => setTimeout(resolve, 120)) + + try { + return handle(method, path, body, auth) as T + } catch (error) { + if (error instanceof DemoHttpError) { + if (error.status === 401) { + clearToken() + throw new UnauthorizedError(error.message) + } + if (error.status === 403) throw new ForbiddenError(error.message) + throw new ApiError(error.status, error.message) + } + throw error + } +} + async function readError(response: Response): Promise { let text = '' try { diff --git a/frontend/src/api/demo/backend.ts b/frontend/src/api/demo/backend.ts new file mode 100644 index 0000000..1660d80 --- /dev/null +++ b/frontend/src/api/demo/backend.ts @@ -0,0 +1,475 @@ +import type { + Book, BookDetailResponse, CatalogCandidate, Customer, Page, Profile, Transaction, +} from '../../types/domain' +import { LOAN_LIMIT, db, helpers, save } from './store' + +/** + * An in-browser stand-in for the Spring backend, used when the site is published with nowhere to + * call. It answers the same paths with the same shapes, and keeps the rules that matter: an + * administrator sees the admin screens, a member only their own loans, a book is out or it is not, + * and nobody borrows against somebody else's membership. + * + * It is not the real backend. Data is per-browser, and there is no cryptography behind the token. + */ + +export class DemoHttpError extends Error { + constructor(readonly status: number, message: string) { + super(message) + } +} + +interface Ctx { + username: string + role: 'ADMIN' | 'USER' + customerId?: string +} + +const TOKEN_PREFIX = 'Bearer demo.' + +function contextFrom(auth: string | null): Ctx | null { + if (!auth?.startsWith(TOKEN_PREFIX)) return null + const username = decodeURIComponent(auth.slice(TOKEN_PREFIX.length)) + const user = db().users.find((u) => u.username === username) + return user ? { username: user.username, role: user.role, customerId: user.customerId } : null +} + +function requireCtx(auth: string | null): Ctx { + const ctx = contextFrom(auth) + if (!ctx) throw new DemoHttpError(401, 'Session expired. Please sign in again.') + return ctx +} + +function requireAdmin(auth: string | null): Ctx { + const ctx = requireCtx(auth) + if (ctx.role !== 'ADMIN') throw new DemoHttpError(403, 'You do not have access to this area.') + return ctx +} + +function page(rows: T[], p: number, size: number): Page { + const start = p * size + return { + data: rows.slice(start, start + size), + totalItems: rows.length, + currentPage: p, + totalPages: Math.max(1, Math.ceil(rows.length / size)), + } +} + +const num = (q: URLSearchParams, k: string, d: number) => Number(q.get(k) ?? d) || d + +const withAvailability = (book: Book): Book => ({ + ...book, + available: !helpers.openLoanForBook(book.bookId), +}) + +const hydrate = (t: Transaction): Transaction => ({ + ...t, + customer: helpers.customer(t.customerId), + book: helpers.book(t.bookId), +}) + +/** Stands in for the Open Library search, so Discover has something to show. */ +function catalogue(query: string): CatalogCandidate[] { + const q = query.trim() + if (!q) return [] + const held = new Set(db().books.map((b) => b.isbn)) + const n = q.length + return [ + { title: `${q} and Other Essays`, isbn: `978-1-000-${(n * 7919) % 100000}-0`, publicationYear: 2015, authors: ['A. Writer'], coverId: null }, + { title: `The Book of ${q}`, isbn: `978-1-001-${(n * 104729) % 100000}-1`, publicationYear: 2001, authors: ['B. Author'], coverId: null }, + { title: `${q}: A History`, isbn: `978-1-002-${(n * 1299709) % 100000}-2`, publicationYear: 1994, authors: ['C. Historian'], coverId: null }, + ].map((c) => ({ ...c, stocked: held.has(c.isbn) })) +} + +export function handle(method: string, path: string, body: unknown, auth: string | null): unknown { + const [rawPath, rawQuery = ''] = path.split('?') + const q = new URLSearchParams(rawQuery) + const seg = rawPath.split('/').filter(Boolean) + const payload = (body ?? {}) as Record + const state = db() + + const str = (k: string, fallback = '') => String(payload[k] ?? fallback) + + // ---- authentication ------------------------------------------------------------------------ + if (method === 'POST' && rawPath === '/api/login') { + const user = state.users.find( + (u) => u.username === str('username') && u.password === str('password'), + ) + if (!user) throw new DemoHttpError(401, 'Those credentials were not recognised.') + return { + message: 'Signed in.', + token: `${TOKEN_PREFIX}${encodeURIComponent(user.username)}`, + username: user.username, + role: user.role, + customerId: user.customerId, + } + } + + if (method === 'POST' && rawPath === '/api/register') { + const username = str('username').trim() + if (!username) throw new DemoHttpError(400, 'A username is required.') + if (state.users.some((u) => u.username === username)) { + throw new DemoHttpError(409, 'That username is already taken.') + } + const customer: Customer = { + customerId: helpers.uuid(), + name: str('name', username), + email: str('email'), + privileges: true, + } + state.customers.push(customer) + // Registration only ever creates members, exactly as RegistrationController does. + state.users.push({ + username, + password: str('password'), + role: 'USER', + customerId: customer.customerId, + }) + save() + return { message: 'Welcome to the library.', username, customerId: customer.customerId } + } + + if (method === 'POST' && rawPath === '/api/logout') return { message: 'Signed out.' } + + if (method === 'GET' && rawPath === '/api/me') { + const ctx = requireCtx(auth) + return { username: ctx.username, role: ctx.role, customerId: ctx.customerId } + } + + if (method === 'POST' && rawPath === '/api/change-password') { + const ctx = requireCtx(auth) + const user = state.users.find((u) => u.username === ctx.username)! + if (user.password !== str('currentPassword')) { + throw new DemoHttpError(400, 'That is not your current password.') + } + user.password = str('newPassword') + save() + return { message: 'Password changed.' } + } + + if (method === 'GET' && rawPath === '/api/profile') { + const ctx = requireCtx(auth) + const customer = helpers.customer(ctx.customerId) + const profile: Profile = { + username: ctx.username, + role: ctx.role, + member: Boolean(customer), + loanLimit: LOAN_LIMIT, + ...(customer && { + name: customer.name, + email: customer.email, + activeLoans: helpers.activeLoansFor(customer.customerId).length, + }), + } + return profile + } + + if (rawPath === '/api/reminders') { + const ctx = requireCtx(auth) + const customer = helpers.customer(ctx.customerId) + if (method === 'PUT') { + const enabled = Boolean(payload.enabled) + state.reminders[ctx.username] = enabled + save() + return { message: 'Saved.', enabled, email: customer?.email ?? '' } + } + return { + supported: Boolean(customer), + enabled: state.reminders[ctx.username] ?? false, + email: customer?.email ?? '', + } + } + + // ---- catalogue ----------------------------------------------------------------------------- + if (method === 'GET' && rawPath === '/books/paginated') { + requireCtx(auth) + const query = (q.get('query') ?? '').toLowerCase() + const sortBy = q.get('sortBy') ?? 'title' + let rows = state.books.map(withAvailability) + if (query) { + rows = rows.filter( + (b) => + b.title.toLowerCase().includes(query) || + b.isbn.includes(query) || + b.authors.some((a) => a.name.toLowerCase().includes(query)), + ) + } + rows.sort((a, b) => + sortBy === 'publicationYear' + ? a.publicationYear - b.publicationYear + : a.title.localeCompare(b.title), + ) + return page(rows, num(q, 'page', 0), num(q, 'size', 25)) + } + + if (method === 'GET' && rawPath === '/books/discover') { + requireCtx(auth) + const hits = catalogue(q.get('query') ?? '') + return { data: hits, currentPage: 0, totalItems: hits.length, totalPages: 1 } + } + + if (method === 'POST' && rawPath === '/books/discover') { + requireCtx(auth) + const names = (payload.authors as string[] | undefined) ?? [] + const book: Book = { + bookId: helpers.uuid(), + title: str('title'), + isbn: str('isbn'), + publicationYear: Number(payload.publicationYear) || 0, + createdAt: helpers.iso(new Date()), + authors: names.map((name) => ({ authorId: helpers.uuid(), name, bio: 'Writer.' })), + available: true, + description: null, + } + state.books.push(book) + state.authors.push(...book.authors) + save() + return { message: 'Added to the shelves.', bookId: book.bookId } + } + + if (method === 'GET' && seg[0] === 'books' && seg.length === 2) { + const ctx = requireCtx(auth) + const book = helpers.book(seg[1]) + if (!book) throw new DemoHttpError(404, 'We could not find that book.') + const loan = helpers.openLoanForBook(book.bookId) + const detail: BookDetailResponse = { + data: withAvailability(book), + borrowedByMe: Boolean(loan && loan.customerId === ctx.customerId), + dueDate: loan?.dueDate ?? null, + } + return detail + } + + // ---- authors ------------------------------------------------------------------------------- + if (method === 'GET' && rawPath === '/authors/paginated') { + requireCtx(auth) + const rows = [...state.authors].sort((a, b) => a.name.localeCompare(b.name)) + return page(rows, num(q, 'page', 0), num(q, 'size', 10)) + } + + if (method === 'GET' && seg[0] === 'authors' && seg.length === 2) { + requireCtx(auth) + const found = state.authors.find((a) => a.authorId === seg[1]) + if (!found) throw new DemoHttpError(404, 'We could not find that author.') + return { + ...found, + books: state.books.filter((b) => b.authors.some((a) => a.authorId === found.authorId)), + } + } + + // ---- members (administrators only) ----------------------------------------------------------- + if (method === 'GET' && (rawPath === '/customers/paginated' || rawPath === '/customers/search')) { + requireAdmin(auth) + const query = (q.get('query') ?? '').toLowerCase() + const rows = state.customers + .filter( + (c) => + !query || + c.name.toLowerCase().includes(query) || + c.email.toLowerCase().includes(query), + ) + .sort((a, b) => a.name.localeCompare(b.name)) + return page(rows, num(q, 'page', 0), num(q, 'size', 10)) + } + + if (method === 'GET' && seg[0] === 'customers' && seg.length === 2) { + requireAdmin(auth) + const customer = helpers.customer(seg[1]) + if (!customer) throw new DemoHttpError(404, 'We could not find that member.') + return { + ...customer, + transactions: state.transactions + .filter((t) => t.customerId === customer.customerId) + .map(hydrate), + } + } + + // ---- loans --------------------------------------------------------------------------------- + if (method === 'POST' && seg[0] === 'transactions' && seg[1] === 'borrowBook') { + const ctx = requireCtx(auth) + const customerId = seg[2] + const bookId = seg[3] + // The same rule the backend enforces: your own membership, unless you are the desk. + if (ctx.role !== 'ADMIN' && ctx.customerId !== customerId) { + throw new DemoHttpError(403, 'You can only borrow against your own membership.') + } + if (!helpers.book(bookId)) throw new DemoHttpError(404, 'We could not find that book.') + if (helpers.openLoanForBook(bookId)) { + throw new DemoHttpError(400, 'Failed to borrow book: Book is not available for borrowing.') + } + if (helpers.activeLoansFor(customerId).length >= LOAN_LIMIT) { + throw new DemoHttpError(400, `Failed to borrow book: the loan limit of ${LOAN_LIMIT} is reached.`) + } + const now = new Date() + state.transactions.push({ + transactionId: helpers.uuid(), + customerId, + bookId, + borrowDate: helpers.iso(now), + dueDate: helpers.dueDateFrom(now), + returnDate: null, + extended: false, + }) + save() + return 'Book borrowed successfully.' + } + + if (method === 'POST' && seg[0] === 'transactions' && seg[1] === 'returnBook') { + const ctx = requireCtx(auth) + const loan = helpers.openLoanForBook(seg[2]) + if (!loan) { + throw new DemoHttpError(400, 'Failed to return book: This book has no open loan to return.') + } + if (ctx.role !== 'ADMIN' && loan.customerId !== ctx.customerId) { + throw new DemoHttpError(403, 'You can only return books you have out.') + } + loan.returnDate = helpers.iso(new Date()) + save() + return { message: 'Transaction successful.', transactionId: loan.transactionId } + } + + if (method === 'POST' && seg[0] === 'transactions' && seg[2] === 'extend') { + const ctx = requireCtx(auth) + const loan = state.transactions.find((t) => t.transactionId === seg[1]) + if (!loan) throw new DemoHttpError(404, 'We could not find that loan.') + if (ctx.role !== 'ADMIN' && loan.customerId !== ctx.customerId) { + throw new DemoHttpError(403, 'You can only extend your own loans.') + } + if (loan.extended) throw new DemoHttpError(400, 'That loan has already been extended once.') + loan.extended = true + loan.dueDate = helpers.dueDateFrom(new Date(loan.dueDate)) + save() + return { message: `Loan extended until ${loan.dueDate}`, dueDate: loan.dueDate } + } + + if (method === 'GET' && rawPath === '/transactions/me') { + const ctx = requireCtx(auth) + if (!ctx.customerId) return page([], 0, num(q, 'size', 20)) + const rows = state.transactions + .filter((t) => t.customerId === ctx.customerId) + .map(hydrate) + .reverse() + return page(rows, num(q, 'page', 0), num(q, 'size', 20)) + } + + if (method === 'GET' && seg[0] === 'transactions' && seg[1] === 'history') { + requireAdmin(auth) + const rows = state.transactions + .filter((t) => t.customerId === seg[2]) + .map(hydrate) + .reverse() + return page(rows, num(q, 'page', 0), num(q, 'size', 10)) + } + + // ---- the desk ------------------------------------------------------------------------------ + if (method === 'GET' && rawPath === '/admin/loans') { + requireAdmin(auth) + const activeOnly = q.get('activeOnly') !== 'false' + const rows = state.transactions + .filter((t) => (activeOnly ? !t.returnDate : true)) + .map(hydrate) + .reverse() + return page(rows, num(q, 'page', 0), num(q, 'size', 20)) + } + + if (method === 'POST' && rawPath === '/admin/books') { + requireAdmin(auth) + const title = str('title') + if (state.books.some((b) => b.title.toLowerCase() === title.toLowerCase())) { + throw new DemoHttpError(400, 'Book with the same title already exists.') + } + const incoming = (payload.authors as { name: string; bio?: string }[] | undefined) ?? [] + const book: Book = { + bookId: helpers.uuid(), + title, + isbn: str('isbn'), + publicationYear: Number(payload.publicationYear) || 0, + createdAt: helpers.iso(new Date()), + authors: incoming.map((a) => ({ + authorId: helpers.uuid(), + name: a.name, + bio: a.bio ?? 'Writer.', + })), + available: true, + description: (payload.description as string | null) ?? null, + } + state.books.push(book) + state.authors.push(...book.authors) + save() + return book + } + + if (method === 'PUT' && seg[0] === 'admin' && seg[1] === 'books') { + requireAdmin(auth) + const book = helpers.book(seg[2]) + if (!book) throw new DemoHttpError(404, 'We could not find that book.') + book.title = str('title', book.title) + book.isbn = str('isbn', book.isbn) + book.publicationYear = Number(payload.publicationYear) || book.publicationYear + book.description = (payload.description as string | null) ?? book.description + save() + return { message: 'Book updated.' } + } + + if (method === 'DELETE' && seg[0] === 'admin' && seg[1] === 'books') { + requireAdmin(auth) + if (helpers.openLoanForBook(seg[2])) { + throw new DemoHttpError(400, 'That book is out on loan and cannot be removed.') + } + state.books = state.books.filter((b) => b.bookId !== seg[2]) + save() + return { message: 'Book removed.' } + } + + if (method === 'GET' && rawPath === '/admin/books/lookup') { + requireAdmin(auth) + // There is no external catalogue here; the form is filled in by hand instead. + throw new DemoHttpError(404, 'No catalogue lookup in the demo. Type the details in instead.') + } + + if (method === 'POST' && rawPath === '/admin/books/import') { + requireAdmin(auth) + return { message: 'Bulk import is disabled in the demo.', imported: 0, skipped: 0 } + } + + if (method === 'GET' && rawPath === '/admin/analytics') { + requireAdmin(auth) + const borrows = state.transactions.length + const returns = state.transactions.filter((t) => t.returnDate).length + + const perBook = new Map() + for (const t of state.transactions) { + const book = helpers.book(t.bookId) + if (!book) continue + const row = perBook.get(t.bookId) ?? { title: book.title, isbn: book.isbn, borrowed: 0, returned: 0 } + row.borrowed += 1 + if (t.returnDate) row.returned += 1 + perBook.set(t.bookId, row) + } + + return { + summary: { + booksTracked: perBook.size, + totalBorrows: borrows, + totalReturns: returns, + currentlyOut: borrows - returns, + // Nothing sits between the borrow and this figure here, so it is always current. + streamConnected: true, + lastEventAt: borrows ? new Date().toISOString() : null, + }, + popularBooks: [...perBook.entries()] + .map(([bookId, r]) => ({ + bookId, + title: r.title, + isbn: r.isbn, + timesBorrowed: r.borrowed, + timesReturned: r.returned, + currentlyOut: r.borrowed - r.returned, + })) + .sort((a, b) => b.timesBorrowed - a.timesBorrowed) + .slice(0, num(q, 'limit', 10)), + } + } + + throw new DemoHttpError(404, 'We could not find what you were looking for.') +} diff --git a/frontend/src/api/demo/store.ts b/frontend/src/api/demo/store.ts new file mode 100644 index 0000000..0a4c99c --- /dev/null +++ b/frontend/src/api/demo/store.ts @@ -0,0 +1,138 @@ +import type { Author, Book, Customer, Transaction } from '../../types/domain' + +/** + * The demo's data, and the rules the real backend would enforce. + * + * Everything lives in localStorage under one key, so a visitor's library survives a page reload but + * belongs only to their browser. Clearing site data resets it, which is the intended escape hatch. + */ + +const KEY = 'library.demo.state' +const LOAN_DAYS = 28 +export const LOAN_LIMIT = 5 + +export interface DemoUser { + username: string + password: string + role: 'ADMIN' | 'USER' + customerId?: string +} + +export interface DemoState { + users: DemoUser[] + customers: Customer[] + books: Book[] + authors: Author[] + transactions: Transaction[] + reminders: Record +} + +const uuid = () => + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}` + +const iso = (d: Date) => d.toISOString().slice(0, 10) + +function author(name: string, bio: string): Author { + return { authorId: uuid(), name, bio } +} + +/** Enough of a catalogue that paging, sorting and searching are worth trying. */ +const SEED: [string, string, number, string, string][] = [ + ['The Left Hand of Darkness', '978-0-441-47812-5', 1969, 'Ursula K. Le Guin', 'A envoy to a world without fixed gender.'], + ['A Wizard of Earthsea', '978-0-553-38304-1', 1968, 'Ursula K. Le Guin', 'A boy with a true name and a shadow behind him.'], + ['Dune', '978-0-441-01359-3', 1965, 'Frank Herbert', 'Spice, sand, and a very long game.'], + ['One Hundred Years of Solitude', '978-0-06-088328-7', 1967, 'Gabriel Garcia Marquez', 'Seven generations in Macondo.'], + ['Gödel, Escher, Bach', '978-0-465-02656-2', 1979, 'Douglas Hofstadter', 'Strange loops, and how minds might be one.'], + ['A Brief History of Time', '978-0-553-38016-3', 1988, 'Stephen Hawking', 'Cosmology without the equations.'], + ['The Name of the Rose', '978-0-15-600131-7', 1980, 'Umberto Eco', 'A murder in an abbey with a dangerous library.'], + ['Beloved', '978-1-4000-3341-6', 1987, 'Toni Morrison', 'A house haunted by what slavery did.'], + ['Things Fall Apart', '978-0-385-47454-2', 1958, 'Chinua Achebe', 'Okonkwo, and the world arriving to end his.'], + ['The Master and Margarita', '978-0-14-118014-8', 1967, 'Mikhail Bulgakov', 'The devil visits Moscow, with a cat.'], + ['Invisible Cities', '978-0-15-645380-2', 1972, 'Italo Calvino', 'Marco Polo describes cities that may be one city.'], + ['The Dispossessed', '978-0-06-051275-4', 1974, 'Ursula K. Le Guin', 'Two worlds, one wall, and a physicist between them.'], + ['Never Let Me Go', '978-1-4000-7877-6', 2005, 'Kazuo Ishiguro', 'A boarding school, remembered too fondly.'], + ['The Remains of the Day', '978-0-679-73172-6', 1989, 'Kazuo Ishiguro', 'A butler counts the cost of his own discretion.'], + ['Piranesi', '978-1-63557-563-4', 2020, 'Susanna Clarke', 'A house of endless halls, and the person who lives there.'], + ['Station Eleven', '978-0-8041-7244-8', 2014, 'Emily St. John Mandel', 'A travelling orchestra after the collapse.'], + ['The Overstory', '978-0-393-63552-2', 2018, 'Richard Powers', 'Nine people, and the trees that outlast them.'], + ['Educated', '978-0-399-59050-4', 2018, 'Tara Westover', 'A childhood off the grid, and the way out.'], + ['Sapiens', '978-0-06-231609-7', 2011, 'Yuval Noah Harari', 'How one ape came to run the place.'], + ['The Sixth Extinction', '978-1-250-06218-5', 2014, 'Elizabeth Kolbert', 'The one happening now, and who is causing it.'], + ['Thinking, Fast and Slow', '978-0-374-53355-7', 2011, 'Daniel Kahneman', 'Two systems, and how the quick one fools you.'], + ['The Goldfinch', '978-0-316-05543-7', 2013, 'Donna Tartt', 'A boy, a bomb, and a small Dutch painting.'], + ['Wolf Hall', '978-0-312-42998-0', 2009, 'Hilary Mantel', 'Thomas Cromwell, from the inside.'], + ['The Road', '978-0-307-38789-9', 2006, 'Cormac McCarthy', 'A man and his son, walking south.'], +] + +function seed(): DemoState { + const authors = new Map() + const books: Book[] = SEED.map(([title, isbn, year, who, description]) => { + if (!authors.has(who)) authors.set(who, author(who, 'Writer.')) + return { + bookId: uuid(), + title, + isbn, + publicationYear: year, + createdAt: iso(new Date()), + authors: [authors.get(who)!], + available: true, + description, + } + }) + + const ada: Customer = { customerId: uuid(), name: 'Ada Lovelace', email: 'ada@example.com', privileges: true } + const alan: Customer = { customerId: uuid(), name: 'Alan Turing', email: 'alan@example.com', privileges: true } + + return { + // The administrator exists from the start, exactly as DataInitializer creates it. + users: [ + { username: 'admin', password: 'admin', role: 'ADMIN' }, + { username: 'ada', password: 'ada', role: 'USER', customerId: ada.customerId }, + ], + customers: [ada, alan], + books, + authors: [...authors.values()], + transactions: [], + reminders: {}, + } +} + +let state: DemoState | null = null + +export function db(): DemoState { + if (state) return state + try { + const raw = localStorage.getItem(KEY) + state = raw ? (JSON.parse(raw) as DemoState) : seed() + } catch { + state = seed() + } + return state! +} + +export function save(): void { + try { + localStorage.setItem(KEY, JSON.stringify(db())) + } catch { + // A full or blocked storage quota is not worth failing a demo over. + } +} + +export function reset(): void { + state = seed() + save() +} + +export const helpers = { + uuid, + iso, + dueDateFrom: (from: Date) => iso(new Date(from.getTime() + LOAN_DAYS * 86_400_000)), + activeLoansFor: (customerId: string) => + db().transactions.filter((t) => t.customerId === customerId && !t.returnDate), + openLoanForBook: (bookId: string) => + db().transactions.find((t) => t.bookId === bookId && !t.returnDate), + customer: (id?: string) => db().customers.find((c) => c.customerId === id), + book: (id: string) => db().books.find((b) => b.bookId === id), +} diff --git a/frontend/src/pages/InsightsPage.tsx b/frontend/src/pages/InsightsPage.tsx index e101535..514851c 100644 --- a/frontend/src/pages/InsightsPage.tsx +++ b/frontend/src/pages/InsightsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { DEMO_MODE } from '../api/client' import { analyticsApi } from '../api/services' import { EmptyState, SkeletonRows } from '../components/TableStates' import { ErrorNotice } from '../components/ErrorNotice' @@ -128,8 +129,9 @@ export function InsightsPage() {

- Figures come from Analytics-Service, which rebuilds them from the library.loans{' '} - event stream on every start. + {DEMO_MODE + ? 'Figures are counted from the loans in this browser. With a real backend they come from Analytics-Service instead.' + : 'Figures come from Analytics-Service, which rebuilds them from the library.loans event stream on every start.'}

)} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 6cb2ab4..04e5d51 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import type { FormEvent } from 'react' import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom' -import { BACKEND_UNCONFIGURED } from '../api/client' +import { DEMO_MODE } from '../api/client' import { useAuth } from '../auth/AuthContext' import { BrandMark } from '../components/Layout' @@ -46,10 +46,11 @@ export function LoginPage() {

Welcome back

Sign in with your username and password.

- {BACKEND_UNCONFIGURED && ( + {DEMO_MODE && (

- Preview only. This deployment has no backend configured, so signing in - and registering will not work. The pages themselves are here to look at. + Demo. This copy runs entirely in your browser, so everything works but + the data is yours alone. Sign in as admin / admin to see the + admin screens, ada / ada as a member, or register your own.

)}