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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3274758..075b641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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://.github.io//, 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 diff --git a/.gitignore b/.gitignore index 7a7f4b0..ee74ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ replay_pid*.log # Logs *.log + +# Local secrets +.env + +# Local H2 database file +data/ 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..4d72964 100644 --- a/Analytics-Service/src/main/resources/application.properties +++ b/Analytics-Service/src/main/resources/application.properties @@ -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 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(); + } + } +} 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..c997d7c --- /dev/null +++ b/Library-Management-System-Version-2/Dockerfile @@ -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"] 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..3a092dd --- /dev/null +++ b/Library-Management-System-Version-2/src/main/resources/application-dev.properties @@ -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 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..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 @@ -18,10 +20,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 @@ -38,6 +41,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. @@ -65,7 +71,15 @@ 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 - +# 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. @@ -105,7 +119,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 @@ -113,8 +129,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/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)); + } +} 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/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..9e378ef 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,35 @@ 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 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 - JDK 21 @@ -228,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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..54cb01f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,152 @@ +# 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 + 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 + 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 + # 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: + 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-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"] + 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: + backend-data: + mysql-data: + kafka-data: + +networks: + library: + driver: bridge 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: 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 6803921..73d839a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,6 +9,13 @@ 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. 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 DEMO_MODE = import.meta.env.VITE_DEMO === 'true' + const TOKEN_KEY = 'library.jwt' const SESSION_KEY = 'library.session' @@ -94,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 }) @@ -164,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 c9c96bd..04e5d51 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 { DEMO_MODE } from '../api/client' import { useAuth } from '../auth/AuthContext' import { BrandMark } from '../components/Layout' @@ -45,6 +46,14 @@ export function LoginPage() {

Welcome back

Sign in with your username and password.

+ {DEMO_MODE && ( +

+ 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. +

+ )} + {error && (

{error}