|
|
|
@ -18,12 +18,11 @@ package org.springframework.web.reactive.resource; |
|
|
|
|
|
|
|
|
|
|
|
import java.io.ByteArrayOutputStream; |
|
|
|
import java.io.ByteArrayOutputStream; |
|
|
|
import java.io.IOException; |
|
|
|
import java.io.IOException; |
|
|
|
import java.io.StringWriter; |
|
|
|
import java.nio.CharBuffer; |
|
|
|
import java.nio.charset.Charset; |
|
|
|
import java.nio.charset.Charset; |
|
|
|
import java.nio.charset.StandardCharsets; |
|
|
|
import java.nio.charset.StandardCharsets; |
|
|
|
import java.util.Arrays; |
|
|
|
import java.util.Arrays; |
|
|
|
import java.util.Collection; |
|
|
|
import java.util.Collection; |
|
|
|
import java.util.Collections; |
|
|
|
|
|
|
|
import java.util.Scanner; |
|
|
|
import java.util.Scanner; |
|
|
|
import java.util.function.Consumer; |
|
|
|
import java.util.function.Consumer; |
|
|
|
|
|
|
|
|
|
|
|
@ -35,15 +34,17 @@ import reactor.core.publisher.Mono; |
|
|
|
import reactor.core.publisher.SynchronousSink; |
|
|
|
import reactor.core.publisher.SynchronousSink; |
|
|
|
|
|
|
|
|
|
|
|
import org.springframework.core.io.Resource; |
|
|
|
import org.springframework.core.io.Resource; |
|
|
|
|
|
|
|
import org.springframework.core.io.buffer.DataBuffer; |
|
|
|
|
|
|
|
import org.springframework.core.io.buffer.DataBufferFactory; |
|
|
|
|
|
|
|
import org.springframework.core.io.buffer.DataBufferUtils; |
|
|
|
import org.springframework.lang.Nullable; |
|
|
|
import org.springframework.lang.Nullable; |
|
|
|
import org.springframework.util.DigestUtils; |
|
|
|
import org.springframework.util.DigestUtils; |
|
|
|
import org.springframework.util.FileCopyUtils; |
|
|
|
import org.springframework.util.StreamUtils; |
|
|
|
import org.springframework.util.StringUtils; |
|
|
|
import org.springframework.util.StringUtils; |
|
|
|
import org.springframework.web.server.ServerWebExchange; |
|
|
|
import org.springframework.web.server.ServerWebExchange; |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
/** |
|
|
|
* A {@link ResourceTransformer} implementation that helps handling resources |
|
|
|
* A {@link ResourceTransformer} HTML5 AppCache manifests. |
|
|
|
* within HTML5 AppCache manifests for HTML5 offline applications. |
|
|
|
|
|
|
|
* |
|
|
|
* |
|
|
|
* <p>This transformer: |
|
|
|
* <p>This transformer: |
|
|
|
* <ul> |
|
|
|
* <ul> |
|
|
|
@ -54,15 +55,11 @@ import org.springframework.web.server.ServerWebExchange; |
|
|
|
* of the manifest in order to trigger an appcache reload in the browser. |
|
|
|
* of the manifest in order to trigger an appcache reload in the browser. |
|
|
|
* </ul> |
|
|
|
* </ul> |
|
|
|
* |
|
|
|
* |
|
|
|
* <p>All files that have the ".appcache" file extension, or the extension given in the constructor, |
|
|
|
* <p>All files with an ".appcache" file extension (or the extension given |
|
|
|
* will be transformed by this class. This hash is computed using the content of the appcache manifest |
|
|
|
* to the constructor) will be transformed by this class. The hash is computed |
|
|
|
* and the content of the linked resources; so changing a resource linked in the manifest |
|
|
|
* using the content of the appcache manifest so that changes in the manifest |
|
|
|
* or the manifest itself should invalidate the browser cache. |
|
|
|
* should invalidate the browser cache. This should also work with changes in |
|
|
|
* |
|
|
|
* referenced resources whose links are also versioned. |
|
|
|
* <p>In order to serve manifest files with the proper {@code "text/manifest"} content type, |
|
|
|
|
|
|
|
* it is required to configure it with |
|
|
|
|
|
|
|
* {@code requestedContentTypeResolverBuilder.mediaType("appcache", MediaType.valueOf("text/manifest")} |
|
|
|
|
|
|
|
* in {@code WebFluxConfigurer.configureContentTypeResolver()}. |
|
|
|
|
|
|
|
* |
|
|
|
* |
|
|
|
* @author Rossen Stoyanchev |
|
|
|
* @author Rossen Stoyanchev |
|
|
|
* @author Brian Clozel |
|
|
|
* @author Brian Clozel |
|
|
|
@ -107,70 +104,91 @@ public class AppCacheManifestTransformer extends ResourceTransformerSupport { |
|
|
|
ResourceTransformerChain chain) { |
|
|
|
ResourceTransformerChain chain) { |
|
|
|
|
|
|
|
|
|
|
|
return chain.transform(exchange, inputResource) |
|
|
|
return chain.transform(exchange, inputResource) |
|
|
|
.flatMap(resource -> { |
|
|
|
.flatMap(outputResource -> { |
|
|
|
String name = resource.getFilename(); |
|
|
|
String name = outputResource.getFilename(); |
|
|
|
if (!this.fileExtension.equals(StringUtils.getFilenameExtension(name))) { |
|
|
|
if (!this.fileExtension.equals(StringUtils.getFilenameExtension(name))) { |
|
|
|
return Mono.just(resource); |
|
|
|
return Mono.just(outputResource); |
|
|
|
} |
|
|
|
|
|
|
|
String content = new String(getResourceBytes(resource), DEFAULT_CHARSET); |
|
|
|
|
|
|
|
if (!content.startsWith(MANIFEST_HEADER)) { |
|
|
|
|
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
|
|
|
|
logger.trace("Manifest should start with 'CACHE MANIFEST', skip: " + resource); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return Mono.just(resource); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); |
|
|
|
|
|
|
|
return DataBufferUtils.read(outputResource, bufferFactory, StreamUtils.BUFFER_SIZE) |
|
|
|
|
|
|
|
.reduce(DataBuffer::write) |
|
|
|
|
|
|
|
.flatMap(dataBuffer -> { |
|
|
|
|
|
|
|
CharBuffer charBuffer = DEFAULT_CHARSET.decode(dataBuffer.asByteBuffer()); |
|
|
|
|
|
|
|
DataBufferUtils.release(dataBuffer); |
|
|
|
|
|
|
|
String content = charBuffer.toString(); |
|
|
|
|
|
|
|
return transform(content, outputResource, chain, exchange); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private Mono<? extends Resource> transform(String content, Resource resource, |
|
|
|
|
|
|
|
ResourceTransformerChain chain, ServerWebExchange exchange) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!content.startsWith(MANIFEST_HEADER)) { |
|
|
|
|
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
|
|
|
|
logger.trace("Manifest should start with 'CACHE MANIFEST', skip: " + resource); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return Mono.just(resource); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
|
|
|
|
logger.trace("Transforming resource: " + resource); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return Flux.generate(new LineInfoGenerator(content)) |
|
|
|
|
|
|
|
.concatMap(info -> processLine(info, exchange, resource, chain)) |
|
|
|
|
|
|
|
.reduce(new ByteArrayOutputStream(), (out, line) -> { |
|
|
|
|
|
|
|
writeToByteArrayOutputStream(out, line + "\n"); |
|
|
|
|
|
|
|
return out; |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
.map(out -> { |
|
|
|
|
|
|
|
String hash = DigestUtils.md5DigestAsHex(out.toByteArray()); |
|
|
|
|
|
|
|
writeToByteArrayOutputStream(out, "\n" + "# Hash: " + hash); |
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
logger.trace("Transforming resource: " + resource); |
|
|
|
logger.trace("AppCache file: [" + resource.getFilename()+ "] hash: [" + hash + "]"); |
|
|
|
} |
|
|
|
} |
|
|
|
return Flux.generate(new LineGenerator(content)) |
|
|
|
return new TransformedResource(resource, out.toByteArray()); |
|
|
|
.concatMap(info -> processLine(info, exchange, resource, chain)) |
|
|
|
|
|
|
|
.collect(() -> new LineAggregator(resource, content), LineAggregator::add) |
|
|
|
|
|
|
|
.map(LineAggregator::createResource); |
|
|
|
|
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private static byte[] getResourceBytes(Resource resource) { |
|
|
|
private static void writeToByteArrayOutputStream(ByteArrayOutputStream out, String toWrite) { |
|
|
|
try { |
|
|
|
try { |
|
|
|
return FileCopyUtils.copyToByteArray(resource.getInputStream()); |
|
|
|
byte[] bytes = toWrite.getBytes(DEFAULT_CHARSET); |
|
|
|
|
|
|
|
out.write(bytes); |
|
|
|
} |
|
|
|
} |
|
|
|
catch (IOException ex) { |
|
|
|
catch (IOException ex) { |
|
|
|
throw Exceptions.propagate(ex); |
|
|
|
throw Exceptions.propagate(ex); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private Mono<LineOutput> processLine(LineInfo info, ServerWebExchange exchange, |
|
|
|
private Mono<String> processLine(LineInfo info, ServerWebExchange exchange, |
|
|
|
Resource resource, ResourceTransformerChain chain) { |
|
|
|
Resource resource, ResourceTransformerChain chain) { |
|
|
|
|
|
|
|
|
|
|
|
if (!info.isLink()) { |
|
|
|
if (!info.isLink()) { |
|
|
|
return Mono.just(new LineOutput(info.getLine(), null)); |
|
|
|
return Mono.just(info.getLine()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
String link = toAbsolutePath(info.getLine(), exchange); |
|
|
|
String link = toAbsolutePath(info.getLine(), exchange); |
|
|
|
Mono<String> pathMono = resolveUrlPath(link, exchange, resource, chain) |
|
|
|
return resolveUrlPath(link, exchange, resource, chain) |
|
|
|
.doOnNext(path -> { |
|
|
|
.doOnNext(path -> { |
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
logger.trace("Link modified: " + path + " (original: " + info.getLine() + ")"); |
|
|
|
logger.trace("Link modified: " + path + " (original: " + info.getLine() + ")"); |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
Mono<Resource> resourceMono = chain.getResolverChain() |
|
|
|
|
|
|
|
.resolveResource(null, info.getLine(), Collections.singletonList(resource)); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return Flux.zip(pathMono, resourceMono, LineOutput::new).next(); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static class LineGenerator implements Consumer<SynchronousSink<LineInfo>> { |
|
|
|
private static class LineInfoGenerator implements Consumer<SynchronousSink<LineInfo>> { |
|
|
|
|
|
|
|
|
|
|
|
private final Scanner scanner; |
|
|
|
private final Scanner scanner; |
|
|
|
|
|
|
|
|
|
|
|
@Nullable |
|
|
|
@Nullable |
|
|
|
private LineInfo previous; |
|
|
|
private LineInfo previous; |
|
|
|
|
|
|
|
|
|
|
|
public LineGenerator(String content) { |
|
|
|
|
|
|
|
|
|
|
|
LineInfoGenerator(String content) { |
|
|
|
this.scanner = new Scanner(content); |
|
|
|
this.scanner = new Scanner(content); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Override |
|
|
|
@Override |
|
|
|
public void accept(SynchronousSink<LineInfo> sink) { |
|
|
|
public void accept(SynchronousSink<LineInfo> sink) { |
|
|
|
if (this.scanner.hasNext()) { |
|
|
|
if (this.scanner.hasNext()) { |
|
|
|
@ -194,12 +212,14 @@ public class AppCacheManifestTransformer extends ResourceTransformerSupport { |
|
|
|
|
|
|
|
|
|
|
|
private final boolean link; |
|
|
|
private final boolean link; |
|
|
|
|
|
|
|
|
|
|
|
public LineInfo(String line, @Nullable LineInfo previousLine) { |
|
|
|
|
|
|
|
|
|
|
|
LineInfo(String line, @Nullable LineInfo previousLine) { |
|
|
|
this.line = line; |
|
|
|
this.line = line; |
|
|
|
this.cacheSection = initCacheSectionFlag(line, previousLine); |
|
|
|
this.cacheSection = initCacheSectionFlag(line, previousLine); |
|
|
|
this.link = iniLinkFlag(line, this.cacheSection); |
|
|
|
this.link = iniLinkFlag(line, this.cacheSection); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static boolean initCacheSectionFlag(String line, @Nullable LineInfo previousLine) { |
|
|
|
private static boolean initCacheSectionFlag(String line, @Nullable LineInfo previousLine) { |
|
|
|
if (MANIFEST_SECTION_HEADERS.contains(line.trim())) { |
|
|
|
if (MANIFEST_SECTION_HEADERS.contains(line.trim())) { |
|
|
|
return line.trim().equals(CACHE_HEADER); |
|
|
|
return line.trim().equals(CACHE_HEADER); |
|
|
|
@ -221,6 +241,7 @@ public class AppCacheManifestTransformer extends ResourceTransformerSupport { |
|
|
|
return (line.startsWith("//") || (index > 0 && !line.substring(0, index).contains("/"))); |
|
|
|
return (line.startsWith("//") || (index > 0 && !line.substring(0, index).contains("/"))); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public String getLine() { |
|
|
|
public String getLine() { |
|
|
|
return this.line; |
|
|
|
return this.line; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -234,65 +255,4 @@ public class AppCacheManifestTransformer extends ResourceTransformerSupport { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static class LineOutput { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private final String line; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Nullable |
|
|
|
|
|
|
|
private final Resource resource; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public LineOutput(String line, @Nullable Resource resource) { |
|
|
|
|
|
|
|
this.line = line; |
|
|
|
|
|
|
|
this.resource = resource; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public String getLine() { |
|
|
|
|
|
|
|
return this.line; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Nullable |
|
|
|
|
|
|
|
public Resource getResource() { |
|
|
|
|
|
|
|
return this.resource; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static class LineAggregator { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private final StringWriter writer = new StringWriter(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private final ByteArrayOutputStream baos; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private final Resource resource; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public LineAggregator(Resource resource, String content) { |
|
|
|
|
|
|
|
this.resource = resource; |
|
|
|
|
|
|
|
this.baos = new ByteArrayOutputStream(content.length()); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public void add(LineOutput lineOutput) { |
|
|
|
|
|
|
|
this.writer.write(lineOutput.getLine() + "\n"); |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
byte[] bytes = (lineOutput.getResource() != null ? |
|
|
|
|
|
|
|
DigestUtils.md5Digest(getResourceBytes(lineOutput.getResource())) : |
|
|
|
|
|
|
|
lineOutput.getLine().getBytes(DEFAULT_CHARSET)); |
|
|
|
|
|
|
|
this.baos.write(bytes); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
catch (IOException ex) { |
|
|
|
|
|
|
|
throw Exceptions.propagate(ex); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public TransformedResource createResource() { |
|
|
|
|
|
|
|
String hash = DigestUtils.md5DigestAsHex(this.baos.toByteArray()); |
|
|
|
|
|
|
|
this.writer.write("\n" + "# Hash: " + hash); |
|
|
|
|
|
|
|
if (logger.isTraceEnabled()) { |
|
|
|
|
|
|
|
logger.trace("AppCache file: [" + resource.getFilename()+ "] hash: [" + hash + "]"); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
byte[] bytes = this.writer.toString().getBytes(DEFAULT_CHARSET); |
|
|
|
|
|
|
|
return new TransformedResource(this.resource, bytes); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|