Skip to content

Commit 2dc12bd

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/tech-hardening-29-12-checkstatusresponse-naming
2 parents 143eeb0 + 64a0a38 commit 2dc12bd

19 files changed

Lines changed: 697 additions & 124 deletions

backend/openshift.deploy.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ objects:
137137
value: "true"
138138
- name: ILCR_DATASOURCE_ENABLED
139139
value: ${ILCR_DATASOURCE_ENABLED}
140+
# Story 29.2 — point the Jasper report virtualizer's swap file at the dedicated
141+
# DISK-backed ephemeral volume below, NOT java.io.tmpdir (/tmp here is a Memory-backed
142+
# emptyDir, so spilling there would stay in RAM and defeat the heap relief).
143+
- name: ILCR_REPORTING_SWAP_DIR
144+
value: /var/tmp/report-swap
140145
- name: ORACLEDB_HOST
141146
valueFrom:
142147
secretKeyRef:
@@ -182,9 +187,13 @@ objects:
182187
requests:
183188
cpu: ${CPU_REQUEST}
184189
memory: ${MEMORY_REQUEST}
185-
ephemeral-storage: "200Mi"
190+
ephemeral-storage: "256Mi"
186191
limits:
187192
memory: ${MEMORY_LIMIT}
193+
# Bound total ephemeral (node) storage: base image scratch + the 512Mi report-swap
194+
# volume below (Story 29.2). A print that would exceed this is evicted — a bounded,
195+
# observable failure — rather than filling node disk.
196+
ephemeral-storage: "1Gi"
188197
securityContext:
189198
allowPrivilegeEscalation: false
190199
runAsNonRoot: true
@@ -196,6 +205,8 @@ objects:
196205
volumeMounts:
197206
- name: tmp
198207
mountPath: /tmp
208+
- name: report-swap
209+
mountPath: /var/tmp/report-swap
199210
- name: api-cert
200211
mountPath: /cert
201212
startupProbe:
@@ -221,6 +232,15 @@ objects:
221232
emptyDir:
222233
medium: Memory
223234
sizeLimit: 256Mi
235+
# Story 29.2 — dedicated DISK-backed ephemeral volume for the Jasper report swap file.
236+
# Deliberately NOT medium: Memory (unlike /tmp above): spilling large report page objects
237+
# here relieves the JVM heap onto node ephemeral storage instead of RAM, which is the whole
238+
# point of the virtualizer. Bounded by sizeLimit so a runaway print is evicted (a bounded,
239+
# observable failure) rather than silently consuming node disk. Writable despite the
240+
# read-only root filesystem because it is a mounted volume.
241+
- name: report-swap
242+
emptyDir:
243+
sizeLimit: 512Mi
224244
# Oracle keystore written by the init container, read by the app
225245
- name: api-cert
226246
persistentVolumeClaim:

backend/src/main/java/ca/bc/gov/nrs/ilcr/configuration/SecurityConfiguration.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ public SecurityFilterChain securityFilterChain(
3636
) throws Exception {
3737
http
3838
.csrf(AbstractHttpConfigurer::disable)
39+
// No .cors(...) is configured, and that omission is a DELIBERATE, documented decision
40+
// (Story 29.14) — not an oversight. The browser talks to ONE origin: the Caddy edge
41+
// (frontend/Caddyfile) serves the SPA and reverse-proxies /api* to this backend, so the
42+
// SPA calls the API same-origin and no cross-origin browser request ever reaches it.
43+
// Combined with the stateless bearer-JWT model — no ambient cookie the browser would
44+
// attach cross-site, the same reasoning as the CSRF note above — the SAFE default for
45+
// this threat model is NO CORS. There is deliberately no allowedOrigins("*")/
46+
// allowCredentials surface here to get wrong. If the SPA is ever served from a DIFFERENT
47+
// origin, terminate CORS at the gateway/Caddy route (preferred); only if the gateway
48+
// cannot own it, add a tightly-scoped CorsConfigurationSource (named origin(s), explicit
49+
// methods/headers, credentials only if actually needed) wired via http.cors(...) HERE —
50+
// never a permissive wildcard, and never "*" combined with credentials.
3951
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
4052
.exceptionHandling(exceptions -> exceptions
4153
.authenticationEntryPoint((request, response, exception) ->

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/PrintService.java

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.List;
1010
import java.util.function.Predicate;
1111
import net.sf.jasperreports.engine.JasperPrint;
12+
import net.sf.jasperreports.engine.fill.JRSwapFileVirtualizer;
1213
import org.slf4j.Logger;
1314
import org.slf4j.LoggerFactory;
1415
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -37,26 +38,29 @@ public class PrintService {
3738

3839
private final ReportService reportService;
3940
private final MillContextService millContextService;
41+
private final ReportVirtualizerFactory virtualizerFactory;
4042

41-
public PrintService(ReportService reportService, MillContextService millContextService) {
43+
public PrintService(ReportService reportService, MillContextService millContextService,
44+
ReportVirtualizerFactory virtualizerFactory) {
4245
this.reportService = reportService;
4346
this.millContextService = millContextService;
47+
this.virtualizerFactory = virtualizerFactory;
4448
}
4549

4650
/**
4751
* Render the combined Print Schedules PDF for the validated context and selection.
4852
*
4953
* @param context the validated (millId, year) pair
5054
* @param request the print selection (schedule flags + "all" + print options)
51-
* @return the combined, bookmarked PDF bytes
55+
* @return the filled report, ready to stream to the response (the caller closes it after export)
5256
* @throws ScheduleNotFoundException 404 — no selected in-scope schedule has any data (ERR-005)
5357
*/
5458
// Deliberately NOT @Transactional: this is six independent read-only reads, each *Service.getScheduleN
5559
// managing its own transaction and the Schedule 9 fill borrowing (and releasing) its own connection. A
5660
// method-wide readOnly transaction would pin ONE pooled connection for the whole render while the
5761
// Schedule 9 fill grabs a SECOND — two connections per /print on a maximum-pool-size of 5, so a handful
5862
// of concurrent prints would exhaust the pool and block until the 30s timeout.
59-
public byte[] render(MillYearContext context, PrintRequest request) {
63+
public RenderedReport render(MillYearContext context, PrintRequest request) {
6064
PrintOptions options =
6165
new PrintOptions(request.printScheduleInformation(), request.printComments());
6266
Predicate<ScheduleKey> selected = selectionOf(request);
@@ -65,6 +69,7 @@ public byte[] render(MillYearContext context, PrintRequest request) {
6569
// The ONLY requested content is the deferred Mill Information report — no in-scope schedule
6670
// and no content option. That yields no PDF for a reason unrelated to missing schedule data,
6771
// so surface the honest "not yet available" rather than the misleading "Schedule not found.".
72+
// Thrown before a virtualizer is created, so nothing to clean up.
6873
log.info("Mill-information-report-only selection for mill {} year {} — report not yet available",
6974
context.millId(), context.year());
7075
throw new MillInformationReportUnavailableException();
@@ -74,33 +79,48 @@ public byte[] render(MillYearContext context, PrintRequest request) {
7479
// instead of re-querying it for each of the five bean sections.
7580
String millTitleBlock = millContextService.resolveMillTitleBlock(context.millId());
7681

77-
List<JasperPrint> sections = new ArrayList<>();
78-
for (ScheduleKey key : ScheduleKey.values()) {
79-
if (!selected.test(key)) {
80-
continue;
82+
// One virtualizer for the whole combined fill (Story 29.2): every section's page objects spill to
83+
// the SAME swap file, so an "all schedules" print never pins the full section graph on the heap.
84+
JRSwapFileVirtualizer virtualizer = virtualizerFactory.create();
85+
boolean ownershipTransferred = false;
86+
try {
87+
List<JasperPrint> sections = new ArrayList<>();
88+
for (ScheduleKey key : ScheduleKey.values()) {
89+
if (!selected.test(key)) {
90+
continue;
91+
}
92+
// BR-08: pass the section's bookmark title so its template renders the top-level PDF outline
93+
// anchor. Every rendered schedule gets exactly one bookmark, including a single-schedule print.
94+
JasperPrint print = reportService.fillSection(
95+
key, context.millId(), context.year(), options, millTitleBlock, key.bookmarkTitle(),
96+
virtualizer);
97+
if (print == null) {
98+
// BR-09 skip-empty: a selected schedule with no data contributes nothing and never aborts.
99+
log.info("Skipping {} for mill {} year {} — no data",
100+
key, context.millId(), context.year());
101+
continue;
102+
}
103+
sections.add(print);
81104
}
82-
// BR-08: pass the section's bookmark title so its template renders the top-level PDF outline
83-
// anchor. Every rendered schedule gets exactly one bookmark, including a single-schedule print.
84-
JasperPrint print = reportService.fillSection(
85-
key, context.millId(), context.year(), options, millTitleBlock, key.bookmarkTitle());
86-
if (print == null) {
87-
// BR-09 skip-empty: a selected schedule with no data contributes nothing and never aborts.
88-
log.info("Skipping {} for mill {} year {} — no data",
89-
key, context.millId(), context.year());
90-
continue;
91-
}
92-
sections.add(print);
93-
}
94105

95-
logUnimplementedSelections(request);
106+
logUnimplementedSelections(request);
96107

97-
if (sections.isEmpty()) {
98-
// All-empty (S11): no selected content produced any section → no PDF, 404 ERR-005.
99-
throw new ScheduleNotFoundException();
108+
if (sections.isEmpty()) {
109+
// All-empty (S11): no selected content produced any section → no PDF, 404 ERR-005.
110+
throw new ScheduleNotFoundException();
111+
}
112+
log.info("Combining {} section(s) into one PDF for mill {} year {}",
113+
sections.size(), context.millId(), context.year());
114+
RenderedReport report = new RenderedReport(sections, virtualizer);
115+
ownershipTransferred = true;
116+
return report;
117+
} finally {
118+
// All-empty (404) or a fill failure produces no PDF, so clean the swap file here; otherwise
119+
// ownership passes to the RenderedReport, which the streaming caller closes after export.
120+
if (!ownershipTransferred) {
121+
virtualizer.cleanup();
122+
}
100123
}
101-
log.info("Combining {} section(s) into one PDF for mill {} year {}",
102-
sections.size(), context.millId(), context.year());
103-
return reportService.exportPdf(sections);
104124
}
105125

106126
/**
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package ca.bc.gov.nrs.ilcr.reporting;
2+
3+
import java.io.OutputStream;
4+
import java.util.List;
5+
import net.sf.jasperreports.engine.JRException;
6+
import net.sf.jasperreports.engine.JasperPrint;
7+
import net.sf.jasperreports.engine.fill.JRSwapFileVirtualizer;
8+
import net.sf.jasperreports.export.SimpleExporterInput;
9+
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput;
10+
import net.sf.jasperreports.pdf.JRPdfExporter;
11+
12+
/**
13+
* One filled report ready to export (Story 29.2): the filled sections plus the {@link
14+
* JRSwapFileVirtualizer} their fill spilled to. {@link #writeTo(OutputStream)} exports the sections
15+
* straight to a caller-supplied stream — the servlet output stream — so the PDF is never accumulated
16+
* as a {@code byte[]} on the heap; {@link #close()} disposes the virtualizer's swap file.
17+
*
18+
* <p>AutoCloseable so the streaming caller (the controller's {@code StreamingResponseBody}) cleans up
19+
* on BOTH success and failure: a render that throws mid-export must not leak a swap file. The fill
20+
* that produced these sections has already happened (and may have thrown the empty-schedule 404)
21+
* BEFORE this holder exists, so streaming and virtualization stay pure transport/memory concerns and
22+
* never move a business outcome after the response is committed.
23+
*/
24+
class RenderedReport implements AutoCloseable {
25+
26+
private final List<JasperPrint> sections;
27+
private final JRSwapFileVirtualizer virtualizer;
28+
29+
RenderedReport(List<JasperPrint> sections, JRSwapFileVirtualizer virtualizer) {
30+
this.sections = sections;
31+
this.virtualizer = virtualizer;
32+
}
33+
34+
/**
35+
* Export the filled sections to ONE PDF written directly to {@code out} (BR-08). Each section's
36+
* top-level bookmark is an in-template outline ANCHOR keyed to its {@code bookmarkTitle} fill
37+
* parameter, NOT JasperReports' batch-mode document bookmarks: the latter only emit a bookmark
38+
* when the export batch holds MORE THAN ONE JasperPrint (JRPdfExporter gates {@code
39+
* addBookmark(getName())} on {@code items.size() > 1}), so a single-schedule {@code /print} would
40+
* silently get an empty outline. The anchor renders one bookmark per section for a single-section
41+
* PDF just as for a combined one; the caller gates it by passing a null bookmark title (the
42+
* standalone Schedule 9 path) to suppress the anchor.
43+
*
44+
* <p>Streams rather than buffers: the exporter output wraps {@code out} directly, so a big "all
45+
* schedules" print never pins the whole PDF as a {@code byte[]} on the JVM heap. {@link
46+
* SimpleOutputStreamExporterOutput} does not own {@code out} (it did not open it), so it leaves the
47+
* servlet stream for the container to close.
48+
*/
49+
void writeTo(OutputStream out) {
50+
JRPdfExporter exporter = new JRPdfExporter();
51+
exporter.setExporterInput(SimpleExporterInput.getInstance(sections));
52+
exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out));
53+
try {
54+
exporter.exportReport();
55+
} catch (JRException e) {
56+
throw new ReportGenerationException("Failed to export the combined report to PDF", e);
57+
}
58+
}
59+
60+
/**
61+
* Dispose the virtualizer, deleting its swap file. The streaming caller wraps this in
62+
* try-with-resources, so it runs on both the success and error paths and a swap file is never
63+
* leaked. The virtualizer owns its swap file (swapOwner=true in {@link ReportVirtualizerFactory}),
64+
* so {@code cleanup()} removes the on-disk file, not just the in-memory page cache.
65+
*/
66+
@Override
67+
public void close() {
68+
if (virtualizer != null) {
69+
virtualizer.cleanup();
70+
}
71+
}
72+
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/ReportController.java

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
import ca.bc.gov.nrs.ilcr.millcontext.MillContextService.MillYearContext;
55
import ca.bc.gov.nrs.ilcr.reporting.api.PrintRequest;
66
import ca.bc.gov.nrs.ilcr.reporting.api.ReportApi;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
79
import org.springframework.http.HttpHeaders;
810
import org.springframework.http.MediaType;
911
import org.springframework.http.ResponseEntity;
1012
import org.springframework.security.access.prepost.PreAuthorize;
1113
import org.springframework.security.core.Authentication;
1214
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
1315
import org.springframework.web.bind.annotation.RestController;
16+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
1417

1518
/**
1619
* Print Schedule PDF endpoints (Epic 20). Authorizes by naming the action (AD-7) — {@code
@@ -28,6 +31,8 @@
2831
@ConditionalOnProperty(name = "ilcr.datasource.enabled", havingValue = "true")
2932
public class ReportController implements ReportApi {
3033

34+
private static final Logger log = LoggerFactory.getLogger(ReportController.class);
35+
3136
private final MillContextService millContextService;
3237
private final ReportService reportService;
3338
private final PrintService printService;
@@ -43,29 +48,56 @@ public ReportController(
4348

4449
@Override
4550
@PreAuthorize("@permissions.hasPermission(authentication, 'VIEW_SCHEDULE')")
46-
public ResponseEntity<byte[]> getSchedule9Pdf(
51+
public ResponseEntity<StreamingResponseBody> getSchedule9Pdf(
4752
String millId, String year, Authentication authentication) {
4853
MillYearContext context = millContextService.validateMillYearActive(millId, year);
49-
byte[] pdf = reportService.renderSchedule9Pdf(context.millId(), context.year());
54+
// Fill synchronously (may throw the empty-schedule 404) BEFORE the response is built; only the
55+
// export streams, so a rejected render still produces a problem+json error, never a half-written PDF.
56+
RenderedReport report = reportService.renderSchedule9(context.millId(), context.year());
5057
String filename = "schedule9_" + context.millId() + "_" + context.year() + ".pdf";
51-
return ResponseEntity.ok()
52-
.contentType(MediaType.APPLICATION_PDF)
53-
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
54-
.body(pdf);
58+
return pdfResponse(filename, context.millId(), context.year(), report);
5559
}
5660

5761
@Override
5862
@PreAuthorize("@permissions.hasPermission(authentication, 'VIEW_SCHEDULE')")
59-
public ResponseEntity<byte[]> printSchedules(
63+
public ResponseEntity<StreamingResponseBody> printSchedules(
6064
String millId, String year, PrintRequest request, Authentication authentication) {
6165
// Guard order: mill/year context first (400/404/409), THEN the selection ladder before any fill.
6266
MillYearContext context = millContextService.validateMillYearActive(millId, year);
6367
validateSelection(request);
64-
byte[] pdf = printService.render(context, request);
68+
RenderedReport report = printService.render(context, request);
69+
return pdfResponse("schedules_print.pdf", context.millId(), context.year(), report);
70+
}
71+
72+
/**
73+
* Stream a filled report as an {@code application/pdf} attachment. The {@link StreamingResponseBody}
74+
* exports directly to the servlet output stream (no full-PDF {@code byte[]} on the heap, Story 29.2)
75+
* and try-with-resources closes the {@link RenderedReport} on both success and failure, so the
76+
* virtualizer's swap file is never leaked. The status + headers are set on the ResponseEntity here,
77+
* before any byte is written, so the attachment filename and content type are always applied.
78+
*
79+
* <p>An export failure surfaces DIFFERENTLY from the pre-fill guards: by the time bytes are written
80+
* the 200 + {@code application/pdf} headers are already committed, so no {@code @ExceptionHandler}
81+
* can turn it into a {@code problem+json} — the client just gets a truncated PDF. It is therefore
82+
* logged at ERROR with the mill/year (the only server-side signal ops can correlate with a user's
83+
* "the PDF won't open") before being rethrown so the container aborts the response. The async render
84+
* runs under {@code spring.mvc.async.request-timeout}; a timeout produces the same truncated shape.
85+
*/
86+
private static ResponseEntity<StreamingResponseBody> pdfResponse(
87+
String filename, long millId, int year, RenderedReport report) {
88+
StreamingResponseBody body = out -> {
89+
try (report) {
90+
report.writeTo(out);
91+
} catch (RuntimeException e) {
92+
log.error("Report export failed after the response was committed for mill {} year {} ({}) — "
93+
+ "the client received a truncated PDF", millId, year, filename, e);
94+
throw e;
95+
}
96+
};
6597
return ResponseEntity.ok()
6698
.contentType(MediaType.APPLICATION_PDF)
67-
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"schedules_print.pdf\"")
68-
.body(pdf);
99+
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
100+
.body(body);
69101
}
70102

71103
/**

0 commit comments

Comments
 (0)