Skip to content

Commit

Permalink
Fix the problem that bundle files can be generated arbitrarily (#6028)
Browse files Browse the repository at this point in the history
#### What type of PR is this?

/kind bug
/area  core
/area plugin
/milestone 2.16.0

#### What this PR does / why we need it:

Before the PR, any user can generate bundle files by providing random query param `v` while requesting bundle files.

This PR refactors the whole bundle file generation method.

1. Do nothing if users provide arbitrary bundle file version
2. Better lock for writing bundle files if not exist

#### Special notes for your reviewer:

1. Request `http://localhost:8090/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js?v=xyz` 
2. Check if the file `xyz.js` in folder `$TMPDIR/halo-plugin-bundle**`

#### Does this PR introduce a user-facing change?

```release-note
None
```
  • Loading branch information
JohnNiang authored Jun 3, 2024
1 parent a26b73e commit ba96118
Show file tree
Hide file tree
Showing 5 changed files with 364 additions and 286 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,36 +32,24 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springdoc.core.fn.builders.operation.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserters;
Expand All @@ -71,7 +59,6 @@
import org.springframework.web.reactive.resource.NoResourceFoundException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
Expand All @@ -98,8 +85,6 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean {

private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher;

private final BufferedPluginBundleResource bufferedPluginBundleResource;

private final WebProperties webProperties;

private final Scheduler scheduler = Schedulers.boundedElastic();
Expand All @@ -111,12 +96,10 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean {
public PluginEndpoint(ReactiveExtensionClient client,
PluginService pluginService,
ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher,
BufferedPluginBundleResource bufferedPluginBundleResource,
WebProperties webProperties) {
this.client = client;
this.pluginService = pluginService;
this.reactiveUrlDataBufferFetcher = reactiveUrlDataBufferFetcher;
this.bufferedPluginBundleResource = bufferedPluginBundleResource;
this.webProperties = webProperties;
}

Expand Down Expand Up @@ -326,52 +309,38 @@ static class RunningStateRequest {
}

private Mono<ServerResponse> fetchJsBundle(ServerRequest request) {
Optional<String> versionOption = request.queryParam("v");
if (versionOption.isEmpty()) {
return pluginService.generateJsBundleVersion()
var versionOption = request.queryParam("v");
return versionOption.map(s -> pluginService.getJsBundle(s).flatMap(
jsRes -> {
var bodyBuilder = ServerResponse.ok()
.cacheControl(bundleCacheControl)
.contentType(MediaType.valueOf("text/javascript"));
if (useLastModified) {
try {
var lastModified = Instant.ofEpochMilli(jsRes.lastModified());
bodyBuilder = bodyBuilder.lastModified(lastModified);
} catch (IOException e) {
if (e instanceof FileNotFoundException) {
return Mono.error(new NoResourceFoundException("bundle.js"));
}
return Mono.error(e);
}
}
return bodyBuilder.body(BodyInserters.fromResource(jsRes));
}))
.orElseGet(() -> pluginService.generateBundleVersion()
.flatMap(version -> ServerResponse
.temporaryRedirect(buildJsBundleUri("js", version))
.cacheControl(CacheControl.noStore())
.build());
}
var version = versionOption.get();
return bufferedPluginBundleResource.getJsBundle(version, pluginService::uglifyJsBundle)
.flatMap(jsRes -> {
var bodyBuilder = ServerResponse.ok()
.cacheControl(bundleCacheControl)
.contentType(MediaType.valueOf("text/javascript"));
if (useLastModified) {
try {
var lastModified = Instant.ofEpochMilli(jsRes.lastModified());
bodyBuilder = bodyBuilder.lastModified(lastModified);
} catch (IOException e) {
if (e instanceof FileNotFoundException) {
return Mono.error(new NoResourceFoundException("bundle.js"));
}
return Mono.error(e);
}
}
return bodyBuilder.body(BodyInserters.fromResource(jsRes));
});
.build()));
}

private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
Optional<String> versionOption = request.queryParam("v");
if (versionOption.isEmpty()) {
return pluginService.generateJsBundleVersion()
.flatMap(version -> ServerResponse
.temporaryRedirect(buildJsBundleUri("css", version))
.cacheControl(CacheControl.noStore())
.build());
}

var version = versionOption.get();
return bufferedPluginBundleResource.getCssBundle(version, pluginService::uglifyCssBundle)
.flatMap(cssRes -> {
return request.queryParam("v")
.map(s -> pluginService.getCssBundle(s).flatMap(cssRes -> {
var bodyBuilder = ServerResponse.ok()
.cacheControl(bundleCacheControl)
.contentType(MediaType.valueOf("text/css"));

if (useLastModified) {
try {
var lastModified = Instant.ofEpochMilli(cssRes.lastModified());
Expand All @@ -383,9 +352,14 @@ private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
return Mono.error(e);
}
}

return bodyBuilder.body(BodyInserters.fromResource(cssRes));
});
}))
.orElseGet(() -> pluginService.generateBundleVersion()
.flatMap(version -> ServerResponse
.temporaryRedirect(buildJsBundleUri("css", version))
.cacheControl(CacheControl.noStore())
.build()));

}

URI buildJsBundleUri(String type, String version) {
Expand Down Expand Up @@ -765,112 +739,4 @@ private Mono<Path> writeToTempFile(Publisher<DataBuffer> content) {
.subscribeOn(this.scheduler);
}

@Component
static class BufferedPluginBundleResource implements DisposableBean {

private final AtomicReference<FileSystemResource> jsBundle = new AtomicReference<>();
private final AtomicReference<FileSystemResource> cssBundle = new AtomicReference<>();

private final ReadWriteLock jsLock = new ReentrantReadWriteLock();
private final ReadWriteLock cssLock = new ReentrantReadWriteLock();

private Path tempDir;

public Mono<Resource> getJsBundle(String version,
Supplier<Flux<DataBuffer>> jsSupplier) {
var fileName = tempFileName(version, ".js");
return Mono.<Resource>defer(() -> {
jsLock.readLock().lock();
try {
var jsBundleResource = jsBundle.get();
if (getResourceIfNotChange(fileName, jsBundleResource) != null) {
return Mono.just(jsBundleResource);
}
} finally {
jsLock.readLock().unlock();
}

jsLock.writeLock().lock();
try {
var oldJsBundle = jsBundle.get();
return writeBundle(fileName, jsSupplier)
.doOnNext(newRes -> jsBundle.compareAndSet(oldJsBundle, newRes));
} finally {
jsLock.writeLock().unlock();
}
}).subscribeOn(Schedulers.boundedElastic());
}

public Mono<Resource> getCssBundle(String version,
Supplier<Flux<DataBuffer>> cssSupplier) {
var fileName = tempFileName(version, ".css");
return Mono.<Resource>defer(() -> {
cssLock.readLock().lock();
try {
var cssBundleResource = cssBundle.get();
if (getResourceIfNotChange(fileName, cssBundleResource) != null) {
return Mono.just(cssBundleResource);
}
} finally {
cssLock.readLock().unlock();
}

cssLock.writeLock().lock();
try {
var oldCssBundle = cssBundle.get();
return writeBundle(fileName, cssSupplier)
.doOnNext(newRes -> cssBundle.compareAndSet(oldCssBundle, newRes));
} finally {
cssLock.writeLock().unlock();
}
}).subscribeOn(Schedulers.boundedElastic());
}

@Nullable
private Resource getResourceIfNotChange(String fileName, Resource resource) {
if (resource != null && resource.exists() && fileName.equals(resource.getFilename())) {
return resource;
}
return null;
}

private Mono<FileSystemResource> writeBundle(String fileName,
Supplier<Flux<DataBuffer>> dataSupplier) {
return Mono.defer(
() -> {
var filePath = createTempFileToStore(fileName);
return DataBufferUtils.write(dataSupplier.get(), filePath)
.then(Mono.fromSupplier(() -> new FileSystemResource(filePath)));
});
}

private Path createTempFileToStore(String fileName) {
try {
if (tempDir == null || !Files.exists(tempDir)) {
this.tempDir = Files.createTempDirectory("halo-plugin-bundle");
}
var path = tempDir.resolve(fileName);
Files.deleteIfExists(path);
return Files.createFile(path);
} catch (IOException e) {
throw new ServerWebInputException("Failed to create temp file.", null, e);
}
}

private String tempFileName(String v, String suffix) {
Assert.notNull(v, "Version must not be null");
Assert.notNull(suffix, "Suffix must not be null");
return v + suffix;
}

@Override
public void destroy() throws Exception {
if (tempDir != null && Files.exists(tempDir)) {
FileSystemUtils.deleteRecursively(tempDir);
}
this.jsBundle.set(null);
this.cssBundle.set(null);
}
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package run.halo.app.core.extension.service;

import java.nio.file.Path;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
Expand Down Expand Up @@ -56,12 +57,48 @@ public interface PluginService {
Flux<DataBuffer> uglifyCssBundle();

/**
* <p>Generate js bundle version for cache control.</p>
* <p>Generate js/css bundle version for cache control.</p>
* This method will list all enabled plugins version and sign it to a string.
*
* @return signed js bundle version by all enabled plugins version.
* @return signed js/css bundle version by all enabled plugins version.
*/
Mono<String> generateJsBundleVersion();
Mono<String> generateBundleVersion();

/**
* Retrieves the JavaScript bundle for all enabled plugins.
*
* <p>This method combines the JavaScript bundles of all enabled plugins into a single bundle
* and returns a representation of this bundle as a resource.
* If the JavaScript bundle already exists and is up-to-date, the existing resource is
* returned; otherwise, a new JavaScript bundle is generated.
*
* <p>Note: This method may perform IO operations and could potentially block, so it should be
* used in a non-blocking environment.
*
* @param version The version of the CSS bundle to retrieve.
* @return A {@code Mono<Resource>} object representing the JavaScript bundle. When this
* {@code Mono} is subscribed to, it emits the JavaScript bundle resource if successful, or
* an error signal if an error occurs.
*/
Mono<Resource> getJsBundle(String version);

/**
* Retrieves the CSS bundle for all enabled plugins.
*
* <p>This method combines the CSS bundles of all enabled plugins into a single bundle and
* returns a representation of this bundle as a resource.
* If the CSS bundle already exists and is up-to-date, the existing resource is returned;
* otherwise, a new CSS bundle is generated.
*
* <p>Note: This method may perform IO operations and could potentially block, so it should be
* used in a non-blocking environment.
*
* @param version The version of the CSS bundle to retrieve.
* @return A {@code Mono<Resource>} object representing the CSS bundle. When this {@code Mono
* } is subscribed to, it emits the CSS bundle resource if successful, or an error signal if
* an error occurs.
*/
Mono<Resource> getCssBundle(String version);

/**
* Enables or disables a plugin by name.
Expand Down
Loading

0 comments on commit ba96118

Please sign in to comment.