diff --git a/api/build.gradle b/api/build.gradle index 5a73852a1..7d9f4fc50 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -29,6 +29,10 @@ dependencies { api 'org.springframework.security:spring-security-oauth2-client' api 'org.springframework.security:spring-security-oauth2-resource-server' + // Cache + api "org.springframework.boot:spring-boot-starter-cache" + api "com.github.ben-manes.caffeine:caffeine" + api "org.springdoc:springdoc-openapi-starter-webflux-ui" api 'org.openapi4j:openapi-schema-validator' api "net.bytebuddy:byte-buddy" diff --git a/application/src/main/java/run/halo/app/cache/CacheEndpoint.java b/application/src/main/java/run/halo/app/cache/CacheEndpoint.java new file mode 100644 index 000000000..14607ce24 --- /dev/null +++ b/application/src/main/java/run/halo/app/cache/CacheEndpoint.java @@ -0,0 +1,52 @@ +package run.halo.app.cache; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springframework.http.HttpStatus.NO_CONTENT; + +import org.springdoc.core.fn.builders.apiresponse.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.core.extension.endpoint.CustomEndpoint; + +@Component +public class CacheEndpoint implements CustomEndpoint { + + private final CacheManager cacheManager; + + public CacheEndpoint(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + @Override + public RouterFunction endpoint() { + return SpringdocRouteBuilder + .route() + .POST("/caches/{name}/invalidation", request -> { + var cacheName = request.pathVariable("name"); + if (cacheManager.getCacheNames().contains(cacheName)) { + var cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.invalidate(); + } + } + return ServerResponse.noContent().build(); + }, builder -> builder + .tag("v1alpha1/Cache") + .operationId("InvalidCache") + .description("Invalidate a cache.") + .parameter(parameterBuilder() + .name("name") + .in(PATH) + .required(true) + .description("Cache name")) + .response(Builder.responseBuilder() + .responseCode(String.valueOf(NO_CONTENT.value()))) + .build()) + .build(); + } + +} diff --git a/application/src/main/java/run/halo/app/cache/CacheWebFilter.java b/application/src/main/java/run/halo/app/cache/CacheWebFilter.java new file mode 100644 index 000000000..23a798123 --- /dev/null +++ b/application/src/main/java/run/halo/app/cache/CacheWebFilter.java @@ -0,0 +1,158 @@ +package run.halo.app.cache; + +import static java.nio.ByteBuffer.allocateDirect; +import static org.springframework.http.HttpHeaders.CACHE_CONTROL; +import static run.halo.app.infra.AnonymousUserConst.isAnonymousUser; + +import java.time.Instant; +import lombok.extern.slf4j.Slf4j; +import org.reactivestreams.Publisher; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpResponseDecorator; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Slf4j +public class CacheWebFilter implements WebFilter, Ordered { + + public static final String REQUEST_TO_CACHE = "RequestCacheWebFilterToCache"; + + public static final String CACHE_NAME = "page-cache"; + + private final Cache cache; + + public CacheWebFilter(CacheManager cacheManager) { + this.cache = cacheManager.getCache(CACHE_NAME); + } + + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName) + .filter(name -> isAnonymousUser(name) && requestCacheable(exchange.getRequest())) + .switchIfEmpty(Mono.defer(() -> chain.filter(exchange).then(Mono.empty()))) + .flatMap(name -> { + var cacheKey = generateCacheKey(exchange.getRequest()); + var cachedResponse = cache.get(cacheKey, CachedResponse.class); + if (cachedResponse != null) { + // cache hit, then write the cached response + return writeCachedResponse(exchange.getResponse(), cachedResponse); + } + // decorate the ServerHttpResponse to cache the response + var decoratedExchange = exchange.mutate() + .response(new CacheResponseDecorator(exchange, cacheKey)) + .build(); + return chain.filter(decoratedExchange); + }); + } + + private boolean requestCacheable(ServerHttpRequest request) { + return HttpMethod.GET.equals(request.getMethod()) + && !hasRequestBody(request) + && enableCacheByCacheControl(request.getHeaders()); + } + + private boolean enableCacheByCacheControl(HttpHeaders headers) { + return headers.getOrEmpty(CACHE_CONTROL) + .stream() + .noneMatch(cacheControl -> + "no-store".equals(cacheControl) || "private".equals(cacheControl)); + } + + private boolean responseCacheable(ServerWebExchange exchange) { + var response = exchange.getResponse(); + if (!MediaType.TEXT_HTML.equals(response.getHeaders().getContentType())) { + return false; + } + var statusCode = response.getStatusCode(); + if (statusCode == null || !statusCode.isSameCodeAs(HttpStatus.OK)) { + return false; + } + return exchange.getAttributeOrDefault(REQUEST_TO_CACHE, false); + } + + private static boolean hasRequestBody(ServerHttpRequest request) { + return request.getHeaders().getContentLength() > 0; + } + + private String generateCacheKey(ServerHttpRequest request) { + return request.getURI().toASCIIString(); + } + + @Override + public int getOrder() { + // The filter should be after org.springframework.security.web.server.WebFilterChainProxy + return Ordered.LOWEST_PRECEDENCE; + } + + private Mono writeCachedResponse(ServerHttpResponse response, + CachedResponse cachedResponse) { + response.setStatusCode(cachedResponse.statusCode()); + response.getHeaders().clear(); + response.getHeaders().addAll(cachedResponse.headers()); + var body = Flux.fromIterable(cachedResponse.body()) + .map(byteBuffer -> response.bufferFactory().wrap(byteBuffer)); + return response.writeWith(body); + } + + class CacheResponseDecorator extends ServerHttpResponseDecorator { + + private final ServerWebExchange exchange; + + private final String cacheKey; + + public CacheResponseDecorator(ServerWebExchange exchange, String cacheKey) { + super(exchange.getResponse()); + this.exchange = exchange; + this.cacheKey = cacheKey; + } + + @Override + @NonNull + public Mono writeWith(@NonNull Publisher body) { + if (responseCacheable(exchange)) { + var response = getDelegate(); + body = Flux.from(body) + .map(dataBuffer -> { + var byteBuffer = allocateDirect(dataBuffer.readableByteCount()); + dataBuffer.toByteBuffer(byteBuffer); + DataBufferUtils.release(dataBuffer); + return byteBuffer.asReadOnlyBuffer(); + }) + .collectSortedList() + .doOnSuccess(byteBuffers -> { + var headers = new HttpHeaders(); + headers.addAll(response.getHeaders()); + var cachedResponse = new CachedResponse(response.getStatusCode(), + headers, + byteBuffers, + Instant.now()); + cache.put(cacheKey, cachedResponse); + }) + .flatMapMany(Flux::fromIterable) + .map(byteBuffer -> response.bufferFactory().wrap(byteBuffer)); + } + // write the response + return super.writeWith(body); + } + } +} diff --git a/application/src/main/java/run/halo/app/cache/CachedResponse.java b/application/src/main/java/run/halo/app/cache/CachedResponse.java new file mode 100644 index 000000000..2a355653c --- /dev/null +++ b/application/src/main/java/run/halo/app/cache/CachedResponse.java @@ -0,0 +1,18 @@ +package run.halo.app.cache; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.List; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; + +/** + * Cached response. Refer to + * here } + */ +public record CachedResponse(HttpStatusCode statusCode, + HttpHeaders headers, + List body, + Instant timestamp) { + +} diff --git a/application/src/main/java/run/halo/app/config/CacheConfiguration.java b/application/src/main/java/run/halo/app/config/CacheConfiguration.java new file mode 100644 index 000000000..4b568ed56 --- /dev/null +++ b/application/src/main/java/run/halo/app/config/CacheConfiguration.java @@ -0,0 +1,20 @@ +package run.halo.app.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.server.WebFilter; +import run.halo.app.cache.CacheWebFilter; + +@EnableCaching +@Configuration +public class CacheConfiguration { + + @Bean + @ConditionalOnProperty(name = "halo.cache.disabled", havingValue = "false") + WebFilter cacheWebFilter(CacheManager cacheManager) { + return new CacheWebFilter(cacheManager); + } +} diff --git a/application/src/main/java/run/halo/app/infra/properties/CacheProperties.java b/application/src/main/java/run/halo/app/infra/properties/CacheProperties.java new file mode 100644 index 000000000..734ee216d --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/properties/CacheProperties.java @@ -0,0 +1,10 @@ +package run.halo.app.infra.properties; + +import lombok.Data; + +@Data +public class CacheProperties { + + private boolean disabled = true; + +} diff --git a/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java b/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java index 77ac2a464..ee5af9a7c 100644 --- a/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java +++ b/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java @@ -58,6 +58,9 @@ public class HaloProperties implements Validator { @Valid private final AttachmentProperties attachment = new AttachmentProperties(); + @Valid + private final CacheProperties cache = new CacheProperties(); + @Override public boolean supports(Class clazz) { return HaloProperties.class.isAssignableFrom(clazz); diff --git a/application/src/main/java/run/halo/app/theme/HaloViewResolver.java b/application/src/main/java/run/halo/app/theme/HaloViewResolver.java index 46602bd7e..d6babe372 100644 --- a/application/src/main/java/run/halo/app/theme/HaloViewResolver.java +++ b/application/src/main/java/run/halo/app/theme/HaloViewResolver.java @@ -15,7 +15,7 @@ import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView; import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; +import run.halo.app.cache.CacheWebFilter; import run.halo.app.theme.finders.FinderRegistry; @Component("thymeleafReactiveViewResolver") @@ -53,8 +53,8 @@ public class HaloViewResolver extends ThymeleafReactiveViewResolver { return themeResolver.getTheme(exchange).flatMap(theme -> { // calculate the engine before rendering setTemplateEngine(engineManager.getTemplateEngine(theme)); - return super.render(model, contentType, exchange) - .subscribeOn(Schedulers.boundedElastic()); + exchange.getAttributes().put(CacheWebFilter.REQUEST_TO_CACHE, true); + return super.render(model, contentType, exchange); }); } diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index da17dee02..0ef85aa6a 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -5,7 +5,7 @@ server: enabled: true error: whitelabel: - enabled: false + enabled: false spring: output: ansi: @@ -27,6 +27,10 @@ spring: cache: cachecontrol: max-age: 365d + cache: + type: caffeine + caffeine: + spec: expireAfterAccess=1h, maximumSize=10000 halo: work-dir: ${user.home}/.halo2 @@ -56,7 +60,7 @@ management: endpoints: web: exposure: - include: ["health", "info", "startup", "globalinfo", "logfile"] + include: ["health", "info", "startup", "globalinfo", "logfile"] endpoint: health: probes: diff --git a/console/packages/api-client/src/.openapi-generator/FILES b/console/packages/api-client/src/.openapi-generator/FILES index c370375b3..116cbe354 100644 --- a/console/packages/api-client/src/.openapi-generator/FILES +++ b/console/packages/api-client/src/.openapi-generator/FILES @@ -45,6 +45,7 @@ api/storage-halo-run-v1alpha1-policy-api.ts api/storage-halo-run-v1alpha1-policy-template-api.ts api/theme-halo-run-v1alpha1-theme-api.ts api/v1alpha1-annotation-setting-api.ts +api/v1alpha1-cache-api.ts api/v1alpha1-config-map-api.ts api/v1alpha1-menu-api.ts api/v1alpha1-menu-item-api.ts diff --git a/console/packages/api-client/src/api.ts b/console/packages/api-client/src/api.ts index 29aab84d2..ce9cc9dc0 100644 --- a/console/packages/api-client/src/api.ts +++ b/console/packages/api-client/src/api.ts @@ -56,6 +56,7 @@ export * from "./api/storage-halo-run-v1alpha1-policy-api"; export * from "./api/storage-halo-run-v1alpha1-policy-template-api"; export * from "./api/theme-halo-run-v1alpha1-theme-api"; export * from "./api/v1alpha1-annotation-setting-api"; +export * from "./api/v1alpha1-cache-api"; export * from "./api/v1alpha1-config-map-api"; export * from "./api/v1alpha1-menu-api"; export * from "./api/v1alpha1-menu-item-api"; diff --git a/console/packages/api-client/src/api/v1alpha1-cache-api.ts b/console/packages/api-client/src/api/v1alpha1-cache-api.ts new file mode 100644 index 000000000..33100db81 --- /dev/null +++ b/console/packages/api-client/src/api/v1alpha1-cache-api.ts @@ -0,0 +1,203 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { Configuration } from "../configuration"; +import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios"; +import globalAxios from "axios"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "../base"; +/** + * V1alpha1CacheApi - axios parameter creator + * @export + */ +export const V1alpha1CacheApiAxiosParamCreator = function ( + configuration?: Configuration +) { + return { + /** + * Invalidate a cache. + * @param {string} name Cache name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + invalidCache: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("invalidCache", "name", name); + const localVarPath = + `/apis/api.console.halo.run/v1alpha1/caches/{name}/invalidation`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; +}; + +/** + * V1alpha1CacheApi - functional programming interface + * @export + */ +export const V1alpha1CacheApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = + V1alpha1CacheApiAxiosParamCreator(configuration); + return { + /** + * Invalidate a cache. + * @param {string} name Cache name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async invalidCache( + name: string, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.invalidCache( + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + }; +}; + +/** + * V1alpha1CacheApi - factory interface + * @export + */ +export const V1alpha1CacheApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = V1alpha1CacheApiFp(configuration); + return { + /** + * Invalidate a cache. + * @param {V1alpha1CacheApiInvalidCacheRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + invalidCache( + requestParameters: V1alpha1CacheApiInvalidCacheRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .invalidCache(requestParameters.name, options) + .then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for invalidCache operation in V1alpha1CacheApi. + * @export + * @interface V1alpha1CacheApiInvalidCacheRequest + */ +export interface V1alpha1CacheApiInvalidCacheRequest { + /** + * Cache name + * @type {string} + * @memberof V1alpha1CacheApiInvalidCache + */ + readonly name: string; +} + +/** + * V1alpha1CacheApi - object-oriented interface + * @export + * @class V1alpha1CacheApi + * @extends {BaseAPI} + */ +export class V1alpha1CacheApi extends BaseAPI { + /** + * Invalidate a cache. + * @param {V1alpha1CacheApiInvalidCacheRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof V1alpha1CacheApi + */ + public invalidCache( + requestParameters: V1alpha1CacheApiInvalidCacheRequest, + options?: AxiosRequestConfig + ) { + return V1alpha1CacheApiFp(this.configuration) + .invalidCache(requestParameters.name, options) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/console/packages/api-client/src/models/secret.ts b/console/packages/api-client/src/models/secret.ts index 9ab026500..7950c50a6 100644 --- a/console/packages/api-client/src/models/secret.ts +++ b/console/packages/api-client/src/models/secret.ts @@ -30,10 +30,10 @@ export interface Secret { apiVersion: string; /** * - * @type {{ [key: string]: string; }} + * @type {{ [key: string]: Array; }} * @memberof Secret */ - data?: { [key: string]: string }; + data?: { [key: string]: Array }; /** * * @type {string} diff --git a/console/src/locales/en.yaml b/console/src/locales/en.yaml index 273887722..4c7066af4 100644 --- a/console/src/locales/en.yaml +++ b/console/src/locales/en.yaml @@ -124,6 +124,11 @@ core: dialog_title: Do you want to refresh the search engine index? dialog_content: This operation will recreate search engine indexes for all published posts. success_message: Refresh search engine index successfully. + refresh_page_cache: + title: Refresh Page Cache + dialog_title: Do you want to refresh the page cache? + dialog_content: This operation will clear the cache for all pages. + success_message: Refresh page cache successfully. user_stats: title: Users comment_stats: diff --git a/console/src/locales/zh-CN.yaml b/console/src/locales/zh-CN.yaml index 33fa5d3ce..a797a3cff 100644 --- a/console/src/locales/zh-CN.yaml +++ b/console/src/locales/zh-CN.yaml @@ -124,6 +124,11 @@ core: dialog_title: 确定要刷新搜索引擎索引吗? dialog_content: 此操作会对所有已发布的文章重新创建搜索引擎索引。 success_message: 刷新成功 + refresh_page_cache: + title: 刷新页面缓存 + dialog_title: 确定要刷新页面缓存吗? + dialog_content: 此操作会清空所有页面的缓存。 + success_message: 刷新成功 user_stats: title: 用户 comment_stats: diff --git a/console/src/locales/zh-TW.yaml b/console/src/locales/zh-TW.yaml index 9e3d2166b..e8355e14d 100644 --- a/console/src/locales/zh-TW.yaml +++ b/console/src/locales/zh-TW.yaml @@ -124,6 +124,11 @@ core: dialog_title: 確定要刷新搜尋引擎索引嗎? dialog_content: 此操作會對所有已發布的文章重新創建搜尋引擎索引。 success_message: 刷新成功 + refresh_page_cache: + title: 重新整理頁面快取 + dialog_title: 確定要重新整理頁面快取嗎? + dialog_content: 這個操作會清空所有頁面的快取。 + success_message: 刷新成功 user_stats: title: 用戶 comment_stats: diff --git a/console/src/modules/dashboard/widgets/QuickLinkWidget.vue b/console/src/modules/dashboard/widgets/QuickLinkWidget.vue index b8defe562..6947e3e4c 100644 --- a/console/src/modules/dashboard/widgets/QuickLinkWidget.vue +++ b/console/src/modules/dashboard/widgets/QuickLinkWidget.vue @@ -9,6 +9,7 @@ import { IconPalette, IconWindowLine, IconSearch, + IconDatabase2Line, VCard, IconUserLine, Dialog, @@ -156,6 +157,32 @@ const actions: Action[] = [ }, permissions: ["system:posts:manage"], }, + { + icon: markRaw(IconDatabase2Line), + title: t( + "core.dashboard.widgets.presets.quicklink.actions.refresh_page_cache.title" + ), + action: () => { + Dialog.warning({ + title: t( + "core.dashboard.widgets.presets.quicklink.actions.refresh_page_cache.dialog_title" + ), + description: t( + "core.dashboard.widgets.presets.quicklink.actions.refresh_page_cache.dialog_content" + ), + confirmText: t("core.common.buttons.confirm"), + cancelText: t("core.common.buttons.cancel"), + onConfirm: async () => { + await apiClient.cache.invalidCache({ name: "page-cache" }); + Toast.success( + t( + "core.dashboard.widgets.presets.quicklink.actions.refresh_page_cache.success_message" + ) + ); + }, + }); + }, + }, ];