From 85238dc5a5c5bf83261b8ba41c0a1e326f3e10a9 Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:27:20 +0000 Subject: [PATCH] assets: match If-None-Match lists and "*", and skip If-Modified-Since when If-None-Match is present --- .../java/io/jooby/handler/AssetHandler.java | 40 +++++++- .../io/jooby/handler/AssetHandlerTest.java | 93 +++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 jooby/src/test/java/io/jooby/handler/AssetHandlerTest.java diff --git a/jooby/src/main/java/io/jooby/handler/AssetHandler.java b/jooby/src/main/java/io/jooby/handler/AssetHandler.java index d5d8029488..238cc60271 100644 --- a/jooby/src/main/java/io/jooby/handler/AssetHandler.java +++ b/jooby/src/main/java/io/jooby/handler/AssetHandler.java @@ -16,6 +16,7 @@ import org.jspecify.annotations.Nullable; import io.jooby.*; +import io.jooby.exception.TypeMismatchException; /** * Handler for static resources represented by the {@link Asset} contract. @@ -98,9 +99,9 @@ public Object apply(Context ctx) throws Exception { CacheControl cacheParams = cacheControl.apply(resolvedPath); // handle If-None-Match + String ifnm = ctx.header("If-None-Match").valueOrNull(); if (cacheParams.isEtag()) { - String ifnm = ctx.header("If-None-Match").valueOrNull(); - if (ifnm != null && ifnm.equals(asset.getEtag())) { + if (ifnm != null && etagMatches(ifnm, asset.getEtag())) { ctx.send(StatusCode.NOT_MODIFIED); asset.close(); return ctx; @@ -109,11 +110,11 @@ public Object apply(Context ctx) throws Exception { } } - // Handle If-Modified-Since + // Handle If-Modified-Since, which is ignored when If-None-Match is present (RFC 9110, 13.2.2) if (cacheParams.isLastModified()) { long lastModified = asset.getLastModified(); if (lastModified > 0) { - long ifms = ctx.header("If-Modified-Since").longValue(-1); + long ifms = ifnm == null ? ifModifiedSince(ctx) : -1; if (lastModified / ONE_SEC <= ifms / ONE_SEC) { ctx.send(StatusCode.NOT_MODIFIED); asset.close(); @@ -246,6 +247,37 @@ public AssetHandler notFound(SneakyThrows.Consumer handler) { return this; } + /** + * If-None-Match is "*" or a list of entity tags, compared with the weak comparison function (RFC + * 9110, section 13.1.2). + */ + private static boolean etagMatches(String ifNoneMatch, String etag) { + if (ifNoneMatch.equals(etag)) { + return true; + } + String opaqueTag = opaqueTag(etag); + for (String tag : ifNoneMatch.split(",")) { + tag = tag.trim(); + if (tag.equals("*") || opaqueTag(tag).equals(opaqueTag)) { + return true; + } + } + return false; + } + + private static String opaqueTag(String etag) { + return etag.startsWith("W/") ? etag.substring(2) : etag; + } + + /** An If-Modified-Since that is not a valid date is ignored (RFC 9110, section 13.1.3). */ + private static long ifModifiedSince(Context ctx) { + try { + return ctx.header("If-Modified-Since").longValue(-1); + } catch (TypeMismatchException x) { + return -1; + } + } + private @Nullable Asset resolve(String filepath) { for (AssetSource source : sources) { Asset asset = source.resolve(filepath); diff --git a/jooby/src/test/java/io/jooby/handler/AssetHandlerTest.java b/jooby/src/test/java/io/jooby/handler/AssetHandlerTest.java new file mode 100644 index 0000000000..d314b67c44 --- /dev/null +++ b/jooby/src/test/java/io/jooby/handler/AssetHandlerTest.java @@ -0,0 +1,93 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.handler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import io.jooby.Context; +import io.jooby.MediaType; +import io.jooby.StatusCode; +import io.jooby.value.Value; +import io.jooby.value.ValueFactory; + +public class AssetHandlerTest { + + private static final Instant LAST_MODIFIED = Instant.parse("2026-01-02T03:04:05Z"); + + private Asset asset; + + private Context ctx; + + @BeforeEach + void setUp() { + asset = mock(Asset.class); + when(asset.getEtag()).thenReturn("W/\"abc\""); + when(asset.getLastModified()).thenReturn(LAST_MODIFIED.toEpochMilli()); + when(asset.getSize()).thenReturn(5L); + when(asset.getContentType()).thenReturn(MediaType.text); + when(asset.stream()) + .thenReturn(new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))); + + ctx = mock(Context.class, RETURNS_DEEP_STUBS); + when(ctx.path((String) any()).value("index.html")).thenReturn("a.txt"); + } + + @ParameterizedTest(name = "If-None-Match: {0}, If-Modified-Since: {1} -> 304: {2}") + @CsvSource( + delimiter = '|', + nullValues = "none", + value = { + "W/\"abc\" | none | true", + "\"abc\" | none | true", + "\"x\", W/\"abc\" | none | true", + "\"x\",\"abc\" | none | true", + "* | none | true", + "\"x\" | none | false", + "\"x\" | last-modified | false", + "none | last-modified | true", + "none | yesterday | false", + }) + void conditionalRequest( + @Nullable String ifNoneMatch, @Nullable String ifModifiedSince, boolean notModified) + throws Exception { + header("If-None-Match", ifNoneMatch); + header( + "If-Modified-Since", + "last-modified".equals(ifModifiedSince) + ? Context.RFC1123.format(LAST_MODIFIED.atOffset(ZoneOffset.UTC)) + : ifModifiedSince); + + new AssetHandler(path -> asset).apply(ctx); + + if (notModified) { + verify(ctx).send(StatusCode.NOT_MODIFIED); + } else { + verify(ctx, never()).send(StatusCode.NOT_MODIFIED); + verify(ctx).send(any(java.io.InputStream.class)); + } + } + + private void header(String name, @Nullable String value) { + ValueFactory factory = new ValueFactory(); + Value header = value == null ? Value.missing(factory, name) : Value.value(factory, name, value); + when(ctx.header(name)).thenReturn(header); + } +}