Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions jooby/src/main/java/io/jooby/handler/AssetHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -246,6 +247,37 @@ public AssetHandler notFound(SneakyThrows.Consumer<Context> 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);
Expand Down
93 changes: 93 additions & 0 deletions jooby/src/test/java/io/jooby/handler/AssetHandlerTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading