From 7fc2ba9be5ef88fb2f068efd8b4a375641855e47 Mon Sep 17 00:00:00 2001 From: austin Date: Mon, 27 Jul 2026 18:45:46 -0500 Subject: [PATCH] A tab icon you can actually see, and photo uploads that accept a photo Two unrelated things the bakery hit on the same afternoon. THE FAVICON was not missing, it was unusable. The head pointed rel=icon at the 1000x1000 logo PNGs, so a browser fetched 71 KB to paint 16 square pixels, and the mark is a vine branch drawn in hairlines -- strokes thinner than one pixel at that size -- which arrives as a grey smudge. Replaced with a real icon set built from one leaf of that branch, filled rather than stroked, because at 16px a silhouette survives and an outline does not. A midrib was drawn first and cut the leaf into two pale slivers at tab size, so it went; the tilt, the two points and the stem carry the shape. The SVG answers prefers-color-scheme itself, which a .ico cannot, so the dark tab strip gets sage on bakery-900 instead of a glowing cream tile. The .ico is listed first on purpose: a browser takes the last format it understands, so reversing the two would hand Chrome the bitmap. PHOTO UPLOADS failed on anything over 1 MB, which is every photo a phone takes. The cause was an absence: nothing configured spring.servlet.multipart, so Boot's 1 MB default applied and the container rejected the file with FileSizeLimitExceededException before it reached ProductPhotoService -- the class whose entire job is turning "whatever came off a phone" into a resized webp. The pipeline could never run on the input it was written for. Now 15 MB a file and 60 MB a request, the latter because the file input is `multiple`. The failure was also ugly, and that is fixed separately: parsed eagerly, an over-sized part throws from inside Tomcat's parameter parsing where no @ExceptionHandler can reach it, so the request died as a 500 and then died again forwarding to /error, because that forward re-parsed the same too-large request (the paired "Exception Processing [ErrorPage...]" lines in the log). resolve-lazily moves the throw into argument binding, where AdminController now catches it and returns the same `problem` flash the domain's other refusals use. max-swallow-size lets the body be discarded so the browser receives that redirect rather than a connection reset. The multipart numbers are asserted rather than trusted, because a default that was never set is exactly the kind of thing that comes back silently. --- .../com/itsthevine/web/AdminController.java | 26 ++++++++ src/main/resources/application.yaml | 24 ++++++++ .../resources/static/apple-touch-icon.png | Bin 0 -> 4864 bytes src/main/resources/static/favicon.ico | Bin 0 -> 15086 bytes src/main/resources/static/favicon.svg | 37 ++++++++++++ .../resources/templates/fragments/head.html | 15 ++++- .../com/itsthevine/web/AdminPagesTest.java | 22 +++++++ .../web/AdminUploadRefusalTest.java | 56 ++++++++++++++++++ .../itsthevine/web/SiteControllerTest.java | 28 +++++++++ 9 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/static/apple-touch-icon.png create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/main/resources/static/favicon.svg create mode 100644 src/test/java/com/itsthevine/web/AdminUploadRefusalTest.java diff --git a/src/main/java/com/itsthevine/web/AdminController.java b/src/main/java/com/itsthevine/web/AdminController.java index 95494f6..cb6d1d0 100644 --- a/src/main/java/com/itsthevine/web/AdminController.java +++ b/src/main/java/com/itsthevine/web/AdminController.java @@ -9,9 +9,14 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import org.springframework.web.servlet.support.RequestContextUtils; + +import jakarta.servlet.http.HttpServletRequest; /** * The catalogue, editable by the person who bakes it — as pages and form posts. @@ -170,4 +175,25 @@ public class AdminController { } return "redirect:/admin"; } + + /** + * A photo bigger than the configured limit, said in a sentence instead of a stack trace. + * + *

This is the same {@code problem} flash the refusals above use, so the editor reads it in the + * same place on the same page. Before it existed, an over-sized file escaped as the container's own + * parsing error: a 500, and then a second failure forwarding to {@code /error}, because that forward + * re-parsed the same too-large request. Reaching this handler at all depends on {@code + * spring.servlet.multipart.resolve-lazily} — parsed eagerly, the throw happens before any handler + * method is chosen and there is nothing here to catch it. + * + *

The flash map is written directly rather than through {@code RedirectAttributes}, which is not + * an argument Spring supplies to an {@code @ExceptionHandler}. + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public String photoTooBig(HttpServletRequest request) { + RequestContextUtils.getOutputFlashMap(request).put("problem", + "That photo is too large. Anything up to 15 MB is fine — a photo straight off a phone " + + "normally is — and we resize it here, so there is no need to shrink it first."); + return "redirect:/admin"; + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3585f49..b5c514d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -11,6 +11,23 @@ spring: open-in-view: false flyway: enabled: true + servlet: + multipart: + # THIS BLOCK IS THE WHOLE REASON PHOTO UPLOADS FAILED. Unset, Boot defaults to a 1 MB max-file-size, + # and a photo off a phone is 3-12 MB — so every real upload died with FileSizeLimitExceededException + # before it reached ProductPhotoService, which exists precisely to resize "whatever came off a phone". + # The resizing pipeline could never run on the input it was written for. + max-file-size: 15MB + # The file input is `multiple`, so one submit can carry several photos; this bounds the whole request + # rather than each part. Four full-size photos at once is a realistic morning's worth of new stock. + max-request-size: 60MB + # Parse when the controller asks for the files, not while Tomcat is reading parameters. Eagerly, an + # oversize part throws from inside the container's parameter parsing, which no @ExceptionHandler can + # reach — the request dies as a 500 and then the forward to /error re-parses and throws again (the + # "Exception Processing [ErrorPage...]" pairs in the log). Lazily, it surfaces as a + # MaxUploadSizeExceededException during argument binding, where AdminController can catch it. + resolve-lazily: true + mail: host: ${SMTP_SERVER:localhost} port: ${SMTP_PORT:25} @@ -29,6 +46,13 @@ spring: ssl: trust: ${SMTP_SERVER:localhost} +server: + tomcat: + # Read and discard the rest of an over-sized body instead of resetting the connection, so the browser + # actually receives the redirect and the message rather than "connection reset". Only reachable now for + # a genuinely enormous file, but that is exactly when a clear answer matters. + max-swallow-size: -1 + platform: web: spa: diff --git a/src/main/resources/static/apple-touch-icon.png b/src/main/resources/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..157e78855d76a1c8cdfa8d5981f3594e7b4a4e1a GIT binary patch literal 4864 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 SnhbQbKp?c939<^|81p}l3{K+! literal 0 HcmV?d00001 diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3bbd8badc9a4a5f2983594be800b61778068fad0 GIT binary patch literal 15086 zcmeHOS#T6p6m1Ht{FD#zJEdazrTF8oez|K|2!g03JBW%T1fo)bNeEkzfNX{>pb)kg z$U;FtmI)ZbB#?#O2<1Qmm3@|MJ(HPfn%U-$G(=fke*dDA`bzH{zNrT|J+u{;)shMRey+AUsvg>)wrVx-f-)Dc*RXYaw?)Z= z2c0oFcl8ditG8~j%x$^s!IReE@?tFPvyLh@FKY>#^!-rkyP=K(uOGV=27!$+F(;P}nzj1lsmES|wy-Cj-kx~6+0WWgbQgH2NwIvl@n zMo}KNlJB|+qq=X=Fzh!qvSXKzLRzK_Q#Ts@}J83YeD%%Yky+)y4qNB z_znIO<-d=+UF^n#`&x3+?+&jR_(07TTK8RjzK`(?hTEv2`cb;5$fHgTo?FP;#^BS~ z0a?VE6guCe6Y(i{An?REdV1J+*MKkEE&8t$mmU?Kl8y9~J!q5K&hmQMvq}`){l3YB z=P?mejh~yI#Yu7wVeFwKU`w2aBo1Wr=LpwpCN^SC_Rc(}@qD_`7t|-K%)~}ph$)Co zn-A`h^i?e359Am1X5x2vd)Yi&8k@dsK6AFWE8<4*M1LK9H`VrsE6RhqAF3#gjU9TQ zo8s?}Iy+f0#rKsvHn7jq=d;w@6|AEtW^Co*rdh|?*3LE^+|8ygnH?3|GdAUD%0~Ji zWCz{#ODoT_X-nrt;Y#o1Pt#^I_(8lFO7eKNwjSQ6h%4cvY`EW)5d3XN_G@AT|H3tg z>G&rvU*y6D{^={1OvQiL-oZZ4Sip^c<|^N~MyZdL?)`e~05>-Duf2QQH2vE>J;8W} zc6m;WDKXwr(!G53wuF!I>UQVtq<*BG`7Se)EDFrp`55!Mc#-n^upJW>j6W;L+KQRr|p6;)`n?< zBptxA6Q+}}F;78Lq{0k3Bl8q5(go+?^7~&6=djhDfx*XQvw(?q)3S9Sj9HCmsoUp! zg_E}67R49b;$SgxO{U*#=`%!}iiq3uvGnkWgKtyZbS2TwTD*t&(QA)nGCAF4ap zLS@op=DkPFY`bi}>&ndr1Kfi?lQiy)w8oTQbdb#tuTM&I5bw{@M9?adC&fzm*qTVt=fqA^sV$xm>3F(gjQSXXS5+My+`gG~{>b^J zq|S<)KiWVZkf^w*)^5?cQ*m>L?RocEsqrv+-?rmtQ}~;`F2th`-nH?@OxPa2!@Eb1 zhZk=hC(9P zW3OW}_VK9soP6FxZ$Dt3r>UjIGTTEZrM?%;p8qD;5{U{6%_%|^?) zgTLPK{+tzyS^Ymvqk=p5Uv8-5wtwdKT-MyFZ(b7^i9G*qZqelbMaI{x)PBy~b!}z6 zbkD)zKLXO+to;;8oCRAn0)@|W-$rJ?zA zs*Id}~Kf`Y969};neTM(nr8|};iLbnGX#G0y zuAIB{9qZb}$Fg;74qJF#qWF=-SE+ + + + + + + + + + + + + diff --git a/src/main/resources/templates/fragments/head.html b/src/main/resources/templates/fragments/head.html index e8c5924..f2851c6 100644 --- a/src/main/resources/templates/fragments/head.html +++ b/src/main/resources/templates/fragments/head.html @@ -13,8 +13,19 @@ - - + + + + diff --git a/src/test/java/com/itsthevine/web/AdminPagesTest.java b/src/test/java/com/itsthevine/web/AdminPagesTest.java index 066072c..67ceaa2 100644 --- a/src/test/java/com/itsthevine/web/AdminPagesTest.java +++ b/src/test/java/com/itsthevine/web/AdminPagesTest.java @@ -1,5 +1,6 @@ package com.itsthevine.web; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; @@ -13,7 +14,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.servlet.autoconfigure.MultipartProperties; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.util.unit.DataSize; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; import org.springframework.test.web.servlet.MockMvc; @@ -79,6 +82,25 @@ class AdminPagesTest { .build(); } + /** + * The bug this guards was an ABSENCE: nothing configured multipart, so Boot's 1 MB default applied and + * every photo off a phone was rejected by the container before {@code ProductPhotoService} — the class + * whose whole job is resizing phone photos — could see it. A default is exactly the kind of thing that + * comes back silently, so the numbers are asserted rather than trusted. + */ + @Test + void photosOffAPhoneFitInsideTheUploadLimits() { + MultipartProperties multipart = context.getBean(MultipartProperties.class); + + assertThat(multipart.getMaxFileSize()).isEqualTo(DataSize.ofMegabytes(15)); + assertThat(multipart.getMaxRequestSize()).isEqualTo(DataSize.ofMegabytes(60)); + // Without this the throw happens inside the container's parameter parsing, where no + // @ExceptionHandler can reach it — which is what made an over-sized photo a 500. + assertThat(multipart.isResolveLazily()).isTrue(); + // The default is 1 MB. If this ever passes, the fix has been undone. + assertThat(multipart.getMaxFileSize()).isNotEqualTo(DataSize.ofMegabytes(1)); + } + @Test void theCatalogueScreenShowsWhatIsOnThePageWithItsPhotos() throws Exception { mvc.perform(get("/admin").with(user("morissa"))) diff --git a/src/test/java/com/itsthevine/web/AdminUploadRefusalTest.java b/src/test/java/com/itsthevine/web/AdminUploadRefusalTest.java new file mode 100644 index 0000000..1375712 --- /dev/null +++ b/src/test/java/com/itsthevine/web/AdminUploadRefusalTest.java @@ -0,0 +1,56 @@ +package com.itsthevine.web; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +/** + * A photo too big for the limit comes back as a sentence, not a stack trace. + * + *

Photo uploads used to fail at 1 MB — no multipart limits were configured, so Boot's default applied + * (see {@code AdminPagesTest#photosOffAPhoneFitInsideTheUploadLimits}, which pins the numbers). Raising + * them fixes the everyday case; this covers what the editor sees when a file really is too large, because + * what happened before was a 500 followed by a second failure forwarding to {@code /error} — that forward + * re-parsed the same over-sized request and threw again. + * + *

Standalone rather than a booted context, and the throw is staged from the service rather than from a + * genuinely huge upload, because MockMvc does not enforce the container's multipart limits — there is no + * way to provoke the real parse failure here. What that leaves worth asserting is the wiring: that the + * handler catches this exception type, writes the same {@code problem} flash the other refusals use, and + * redirects instead of rendering an error page. Whether the exception can reach a handler at all is a + * property of {@code resolve-lazily}, which is asserted separately. + */ +class AdminUploadRefusalTest { + + @Test + void anOversizePhotoIsRefusedOnThePageRatherThanAsAStackTrace() throws Exception { + Catalogue catalogue = mock(Catalogue.class); + doThrow(new MaxUploadSizeExceededException(15_728_640L)) + .when(catalogue).addPhotos(eq(7L), any()); + + MockMvc mvc = MockMvcBuilders.standaloneSetup(new AdminController(catalogue)).build(); + + mvc.perform(multipart("/admin/items/7/photos") + .file(new MockMultipartFile("photos", "cake.jpg", "image/jpeg", new byte[] {1, 2, 3}))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin")) + // The same flash key the domain's own refusals use, so it lands in the same place on the + // page, and it names the limit rather than saying "invalid". + .andExpect(flash().attribute("problem", containsString("too large"))) + .andExpect(flash().attribute("problem", containsString("15 MB"))) + // It should not tell the baker to go and shrink the photo: resizing is this app's job. + .andExpect(flash().attribute("problem", containsString("resize it here"))); + } +} diff --git a/src/test/java/com/itsthevine/web/SiteControllerTest.java b/src/test/java/com/itsthevine/web/SiteControllerTest.java index 1ed50bd..7296b5d 100644 --- a/src/test/java/com/itsthevine/web/SiteControllerTest.java +++ b/src/test/java/com/itsthevine/web/SiteControllerTest.java @@ -60,6 +60,34 @@ class SiteControllerTest { mvc = MockMvcBuilders.webAppContextSetup(context).build(); } + /** + * The tab icon used to be the 1000x1000 logo PNG, which is 71 KB fetched to paint 16 pixels and, being + * a hairline drawing, arrived as a grey smudge at that size. The order matters and is the reason this + * asserts position: a browser takes the LAST icon format it understands, so the .ico has to come first + * or Chrome settles for the bitmap instead of the SVG. + */ + @Test + void theTabIconIsAnIconRatherThanTheFullLogo() throws Exception { + String head = mvc.perform(get("/")).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(head).contains(""); + assertThat(head).contains(""); + assertThat(head).contains(""); + assertThat(head.indexOf("/favicon.ico")).isLessThan(head.indexOf("/favicon.svg")); + // The 1000x1000 logos are no longer offered as icons anywhere. + assertThat(head).doesNotContain("rel=\"icon\" media="); + assertThat(head).doesNotContain("logo_dark.png"); + } + + @Test + void theIconFilesAreActuallyServed() throws Exception { + // A link to a 404 is worse than no link: the browser shows its default and caches the miss. + for (String icon : new String[] {"/favicon.svg", "/favicon.ico", "/apple-touch-icon.png"}) { + mvc.perform(get(icon)).andExpect(status().isOk()); + } + } + @Test void everyPageStatesItsOwnTitleAndDescription() throws Exception { // One generic shell for every page was the SPA's problem, and the reason a controller used to