diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts
index 2ba39163544b..8e87a2de842f 100644
--- a/core-web/apps/dotcms-ui/src/app/app.routes.ts
+++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts
@@ -22,6 +22,12 @@ import { MainCoreLegacyComponent } from './view/components/main-core-legacy/main
import { MainComponentLegacyComponent } from './view/components/main-legacy/main-legacy.component';
const PORTLETS_ANGULAR: Route[] = [
+ {
+ path: 'dotCDN',
+ canActivate: [MenuGuardService],
+ canActivateChild: [MenuGuardService],
+ loadChildren: () => import('@dotcms/portlets/dot-cdn/portlet').then((m) => m.dotCdnRoutes)
+ },
{
path: 'containers',
loadChildren: () =>
diff --git a/core-web/libs/portlets/dot-cdn/.eslintrc.json b/core-web/libs/portlets/dot-cdn/.eslintrc.json
new file mode 100644
index 000000000000..ef536cdfaf37
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+ "extends": ["../../../.eslintrc.base.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/core-web/libs/portlets/dot-cdn/project.json b/core-web/libs/portlets/dot-cdn/project.json
new file mode 100644
index 000000000000..19d8a93794bd
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/project.json
@@ -0,0 +1,13 @@
+{
+ "name": "portlets-dot-cdn-portlet",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/portlets/dot-cdn/src",
+ "prefix": "dot",
+ "projectType": "library",
+ "tags": ["type:feature", "scope:dotcms-ui", "portlet:cdn"],
+ "targets": {
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ }
+ }
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/index.ts b/core-web/libs/portlets/dot-cdn/src/index.ts
new file mode 100644
index 000000000000..44c9365302f3
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/index.ts
@@ -0,0 +1 @@
+export * from './lib/lib.routes';
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn-filters/dot-cdn-filters.component.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn-filters/dot-cdn-filters.component.ts
new file mode 100644
index 000000000000..dcf9b7ffaf2b
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn-filters/dot-cdn-filters.component.ts
@@ -0,0 +1,165 @@
+import { format, isBefore, startOfDay, subDays } from 'date-fns';
+
+import { ChangeDetectionStrategy, Component, computed, model, output, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { ButtonModule } from 'primeng/button';
+import { DatePickerModule } from 'primeng/datepicker';
+import { SelectButtonModule, SelectButtonChangeEvent } from 'primeng/selectbutton';
+
+export interface CdnDateFilter {
+ dateFrom: string;
+ dateTo: string;
+ hourly: boolean;
+}
+
+export interface CdnFilterOption {
+ label: string;
+ value: string;
+}
+
+export const CDN_TIME_PRESETS: CdnFilterOption[] = [
+ { label: 'Today', value: 'today' },
+ { label: '24h', value: 'last24h' },
+ { label: '7d', value: 'last7d' },
+ { label: '30d', value: 'last30d' },
+ { label: '90d', value: 'last90d' },
+ { label: 'Custom', value: 'custom' }
+];
+
+@Component({
+ selector: 'dot-cdn-filters',
+ standalone: true,
+ imports: [FormsModule, SelectButtonModule, DatePickerModule, ButtonModule],
+ template: `
+
+
+
+ @if ($showDatePicker()) {
+
+
+
+
+
+ }
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DotCdnFiltersComponent {
+ readonly presets = CDN_TIME_PRESETS;
+ readonly $today = signal(startOfDay(new Date()));
+ readonly $selectedPreset = model('last30d');
+ readonly $customRange = model(null);
+ readonly $rangeStart = signal(null);
+
+ readonly $showDatePicker = computed(() => this.$selectedPreset() === 'custom');
+
+ filterChange = output();
+
+ onPresetChange(event: SelectButtonChangeEvent): void {
+ const preset = event.value as string;
+ if (preset === 'custom') {
+ return;
+ }
+
+ this.$customRange.set(null);
+ this.$rangeStart.set(null);
+ this.filterChange.emit(this.resolvePreset(preset));
+ }
+
+ onDateSelect(date: Date): void {
+ const start = this.$rangeStart();
+
+ if (start === null || isBefore(date, start)) {
+ this.$rangeStart.set(startOfDay(date));
+ } else {
+ this.$rangeStart.set(null);
+ const dateFrom = format(start, 'yyyy-MM-dd');
+ const dateTo = format(date, 'yyyy-MM-dd');
+ const diffDays = Math.ceil((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
+
+ this.filterChange.emit({
+ dateFrom,
+ dateTo,
+ hourly: diffDays <= 2
+ });
+ }
+ }
+
+ clearDateRange(): void {
+ this.$rangeStart.set(null);
+ this.$customRange.set(null);
+ }
+
+ onCalendarClosed(): void {
+ const range = this.$customRange();
+ if (!range || range.length !== 2) {
+ this.$rangeStart.set(null);
+ }
+ }
+
+ private resolvePreset(preset: string): CdnDateFilter {
+ const today = format(new Date(), 'yyyy-MM-dd');
+
+ switch (preset) {
+ case 'today':
+ return { dateFrom: today, dateTo: today, hourly: true };
+ case 'last24h':
+ return {
+ dateFrom: format(subDays(new Date(), 1), 'yyyy-MM-dd'),
+ dateTo: today,
+ hourly: true
+ };
+ case 'last7d':
+ return {
+ dateFrom: format(subDays(new Date(), 7), 'yyyy-MM-dd'),
+ dateTo: today,
+ hourly: false
+ };
+ case 'last30d':
+ return {
+ dateFrom: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
+ dateTo: today,
+ hourly: false
+ };
+ case 'last90d':
+ return {
+ dateFrom: format(subDays(new Date(), 90), 'yyyy-MM-dd'),
+ dateTo: today,
+ hourly: false
+ };
+ default:
+ return {
+ dateFrom: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
+ dateTo: today,
+ hourly: false
+ };
+ }
+ }
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html
new file mode 100644
index 000000000000..5de655f32235
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.html
@@ -0,0 +1,183 @@
+@if (vm$ | async; as VM) {
+
+
+
+ Overview
+ Flush Cache
+
+
+
+
+
+
+ @if (VM.cdnDomain) {
+
+ CDN:
+ {{ VM.cdnDomain }}
+
+ }
+
+
+
+
+
+ @for (stats of VM.statsData; track stats) {
+
+
+
+
+ {{ stats.label }}
+
+ @if (VM.isChartLoading) {
+
+ } @else {
+
+ {{ stats.value }}
+
+ }
+
+
+ }
+
+
+
+
+
+ @if (VM.isChartLoading) {
+
+ } @else {
+
+ Bandwidth Served
+
+
+ }
+
+
+
+ @if (VM.isChartLoading) {
+
+ } @else {
+
+ Requests Served
+
+
+ }
+
+
+
+ @if (VM.isChartLoading) {
+
+ } @else {
+
+ Cache Hit Rate
+
+
+ }
+
+
+
+ @if (VM.isChartLoading) {
+
+ } @else {
+
Errors
+
+ }
+
+
+
+
+
+ @if (vmPurgeLoaders$ | async; as VMPurgeLoaders) {
+
+
+
+
Purge URL List
+
+ Purging removes files from CDN cache and re-downloads from your
+ origin. Enter exact CDN URLs, one per line. Use
+ *
+ for wildcards.
+
+
+
+
+
+
+ Purge Entire Cache
+
+
+ Forces everything to be re-downloaded from your origin. May
+ cause a traffic spike if your CDN domain is heavily trafficked.
+
+
+
+
+
+ }
+
+
+
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.scss b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.scss
new file mode 100644
index 000000000000..2085f65e2bf7
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.scss
@@ -0,0 +1,14 @@
+p-skeleton {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+dot-spinner {
+ margin: 0 auto;
+
+ width: max-content;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.ts
new file mode 100644
index 000000000000..2e16967adcc3
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.component.ts
@@ -0,0 +1,207 @@
+import { ChartOptions } from 'chart.js';
+import { Observable } from 'rxjs';
+
+import { AsyncPipe, NgStyle } from '@angular/common';
+import { Component, inject, OnInit } from '@angular/core';
+import {
+ FormsModule,
+ ReactiveFormsModule,
+ UntypedFormBuilder,
+ UntypedFormGroup
+} from '@angular/forms';
+
+import { ButtonModule } from 'primeng/button';
+import { ChartModule } from 'primeng/chart';
+import { SkeletonModule } from 'primeng/skeleton';
+import { TabsModule } from 'primeng/tabs';
+import { TextareaModule } from 'primeng/textarea';
+
+import { take } from 'rxjs/operators';
+
+import { DotIconComponent, DotSpinnerComponent } from '@dotcms/ui';
+
+import { CdnDateFilter, DotCdnFiltersComponent } from './dot-cdn-filters/dot-cdn-filters.component';
+import { CdnChartOptions, DotCDNState } from './dot-cdn.models';
+import { DotCDNStore } from './dot-cdn.store';
+
+@Component({
+ selector: 'dot-cdn',
+ templateUrl: './dot-cdn.component.html',
+ styleUrls: ['./dot-cdn.component.scss'],
+ standalone: true,
+ imports: [
+ AsyncPipe,
+ NgStyle,
+ FormsModule,
+ ReactiveFormsModule,
+ TabsModule,
+ ChartModule,
+ ButtonModule,
+ TextareaModule,
+ SkeletonModule,
+ DotIconComponent,
+ DotSpinnerComponent,
+ DotCdnFiltersComponent
+ ],
+ providers: [DotCDNStore]
+})
+export class DotCDNComponent implements OnInit {
+ private fb = inject(UntypedFormBuilder);
+ private dotCdnStore = inject(DotCDNStore);
+
+ purgeZoneForm: UntypedFormGroup;
+ vm$: Observable> =
+ this.dotCdnStore.vm$;
+ vmPurgeLoaders$: Observable> =
+ this.dotCdnStore.vmPurgeLoaders$;
+ chartHeight = '25rem';
+ options: CdnChartOptions;
+
+ ngOnInit(): void {
+ this.setChartOptions();
+ this.purgeZoneForm = this.fb.group({
+ purgeUrlsTextArea: ''
+ });
+ }
+
+ onFilterChange(filter: CdnDateFilter): void {
+ this.dotCdnStore.loadStats(filter);
+ }
+
+ purgePullZone(): void {
+ this.dotCdnStore.purgeCDNCacheAll();
+ }
+
+ purgeUrls(): void {
+ const urls: string[] = this.purgeZoneForm
+ .get('purgeUrlsTextArea')
+ .value.split('\n')
+ .map((url: string) => url.trim());
+
+ this.dotCdnStore
+ .purgeCDNCache(urls)
+ .pipe(take(1))
+ .subscribe(() => {
+ this.purgeZoneForm.setValue({ purgeUrlsTextArea: '' });
+ });
+ }
+
+ private setChartOptions(): void {
+ const bandwidthOptions: ChartOptions = {
+ responsive: true,
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: function (context) {
+ return `${context.dataset.label}: ${context.formattedValue}`;
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ ticks: {
+ maxTicksLimit: 15,
+ maxRotation: 45,
+ minRotation: 0
+ }
+ },
+ y: {
+ beginAtZero: true,
+ ticks: {
+ callback: function (value: number): string {
+ return value.toLocaleString('en-US');
+ }
+ }
+ }
+ }
+ };
+
+ const requestOptions: ChartOptions = {
+ responsive: true,
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: function (context) {
+ return `${context.dataset.label}: ${Number(context.raw).toLocaleString('en-US')}`;
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ ticks: {
+ maxTicksLimit: 15,
+ maxRotation: 45,
+ minRotation: 0
+ }
+ },
+ y: {
+ beginAtZero: true,
+ ticks: {
+ precision: 0,
+ callback: function (value: number): string {
+ return value.toLocaleString('en-US');
+ }
+ }
+ }
+ }
+ };
+
+ const commonScales = {
+ x: { ticks: { maxTicksLimit: 10, maxRotation: 45, minRotation: 0 } },
+ y: { beginAtZero: true }
+ };
+
+ const cacheHitRateOptions: ChartOptions = {
+ responsive: true,
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: (context) =>
+ `${context.dataset.label}: ${Number(context.raw).toFixed(2)}%`
+ }
+ }
+ },
+ scales: {
+ ...commonScales,
+ y: {
+ beginAtZero: true,
+ max: 100,
+ ticks: {
+ callback: (value: number): string => `${value}%`
+ }
+ }
+ }
+ };
+
+ const errorOptions: ChartOptions = {
+ responsive: true,
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: (context) =>
+ `${context.dataset.label}: ${Number(context.raw).toLocaleString('en-US')}`
+ }
+ }
+ },
+ scales: {
+ ...commonScales,
+ y: {
+ beginAtZero: true,
+ ticks: {
+ precision: 0,
+ callback: (value: number): string => value.toLocaleString('en-US')
+ }
+ }
+ }
+ };
+
+ this.options = {
+ bandwidthUsedChart: bandwidthOptions,
+ requestsServedChart: requestOptions,
+ cacheHitRateChart: cacheHitRateOptions,
+ errorChart: errorOptions
+ };
+ }
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.models.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.models.ts
new file mode 100644
index 000000000000..b857aa4efae1
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.models.ts
@@ -0,0 +1,81 @@
+import { ChartOptions } from 'chart.js';
+
+export interface ChartDataSet {
+ label: string;
+ data: string[];
+ borderColor?: string;
+ fill?: boolean;
+}
+
+export interface DotCDNStats {
+ stats: {
+ bandwidthPretty: string;
+ bandwidthUsedChart: { [key: string]: number };
+ requestsServedChart: { [key: string]: number };
+ cacheHitRateChart: { [key: string]: number };
+ originResponseTimeChart: { [key: string]: number };
+ error4xxChart: { [key: string]: number };
+ error5xxChart: { [key: string]: number };
+ cacheHitRate: number;
+ averageOriginResponseTime: number;
+ dateFrom: string;
+ dateTo: string;
+ geographicDistribution: unknown;
+ totalBandwidthUsed: number;
+ totalRequestsServed: number;
+ cdnDomain: string;
+ };
+}
+
+export interface ChartData {
+ labels: string[];
+ datasets: ChartDataSet[];
+}
+
+export interface DotChartStats {
+ label: string;
+ value: string;
+ icon: string;
+}
+
+export interface PurgeUrlOptions {
+ hostId: string;
+ invalidateAll: boolean;
+ urls?: string[];
+}
+
+export interface DotCDNState {
+ chartBandwidthData: ChartData;
+ chartRequestsData: ChartData;
+ chartCacheHitRateData: ChartData;
+ chartErrorData: ChartData;
+ statsData: DotChartStats[];
+ isChartLoading: boolean;
+ cdnDomain: string;
+ isPurgeUrlsLoading: boolean;
+ isPurgeZoneLoading: boolean;
+}
+
+export type CdnChartOptions = {
+ bandwidthUsedChart: ChartOptions;
+ requestsServedChart: ChartOptions;
+ cacheHitRateChart: ChartOptions;
+ errorChart: ChartOptions;
+};
+
+export interface PurgeReturnData {
+ success: boolean;
+ invalidateAll: boolean;
+}
+
+export const enum LoadingState {
+ IDLE = 'IDLE',
+ LOADING = 'LOADING',
+ LOADED = 'LOADED'
+}
+
+export const enum Loader {
+ CHART = 'CHART',
+ PURGE_URLS = 'PURGE_URLS',
+ PURGE_PULL_ZONE = 'PURGE_PULL_ZONE'
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.service.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.service.ts
new file mode 100644
index 000000000000..14a89ed49474
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.service.ts
@@ -0,0 +1,76 @@
+import { Observable } from 'rxjs';
+
+import { HttpClient } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+
+import { map, mergeMap } from 'rxjs/operators';
+
+import { SiteService } from '@dotcms/dotcms-js';
+import { DotCMSResponse } from '@dotcms/dotcms-models';
+
+import { DotCDNStats, PurgeReturnData, PurgeUrlOptions } from './dot-cdn.models';
+
+export interface StatsRequest {
+ dateFrom: string;
+ dateTo: string;
+ hourly?: boolean;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class DotCDNService {
+ private http = inject(HttpClient);
+ private siteService = inject(SiteService);
+
+ requestStats(request: StatsRequest): Observable {
+ return this.siteService.getCurrentSite().pipe(
+ map((site) => site.identifier),
+ mergeMap((hostId: string) => {
+ let url =
+ `/api/v1/dotcdn/stats?hostId=${hostId}` +
+ `&dateFrom=${request.dateFrom}&dateTo=${request.dateTo}`;
+
+ if (request.hourly) {
+ url += '&hourly=true';
+ }
+
+ return this.http
+ .get>(url)
+ .pipe(map((response) => response.entity));
+ })
+ );
+ }
+
+ purgeCache(urls?: string[]): Observable {
+ return this.siteService.getCurrentSite().pipe(
+ map((site) => site.identifier),
+ mergeMap((hostId: string) => {
+ return this.purgeUrlRequest({ hostId, invalidateAll: false, urls });
+ }),
+ map((response) => response.entity)
+ );
+ }
+
+ purgeCacheAll(): Observable {
+ return this.siteService.getCurrentSite().pipe(
+ map((site) => site.identifier),
+ mergeMap((hostId: string) => this.purgeUrlRequest({ hostId, invalidateAll: true })),
+ map((response) => response.entity)
+ );
+ }
+
+ private purgeUrlRequest({
+ urls = [],
+ invalidateAll,
+ hostId
+ }: PurgeUrlOptions): Observable> {
+ return this.http.request>('DELETE', '/api/v1/dotcdn', {
+ body: {
+ urls,
+ invalidateAll,
+ hostId
+ }
+ });
+ }
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.store.ts b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.store.ts
new file mode 100644
index 000000000000..d574906dd483
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/dot-cdn.store.ts
@@ -0,0 +1,341 @@
+import { ComponentStore } from '@ngrx/component-store';
+import { tapResponse } from '@ngrx/operators';
+import { format, subDays } from 'date-fns';
+import { Observable, of } from 'rxjs';
+
+import { HttpErrorResponse } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+
+import { finalize, mergeMap, switchMap } from 'rxjs/operators';
+
+import { DotHttpErrorManagerService } from '@dotcms/data-access';
+
+import { CdnDateFilter } from './dot-cdn-filters/dot-cdn-filters.component';
+import {
+ ChartData,
+ DotCDNState,
+ DotCDNStats,
+ DotChartStats,
+ Loader,
+ LoadingState,
+ PurgeReturnData
+} from './dot-cdn.models';
+import { DotCDNService, StatsRequest } from './dot-cdn.service';
+
+const DEFAULT_FILTER: CdnDateFilter = {
+ dateFrom: format(subDays(new Date(), 30), 'yyyy-MM-dd'),
+ dateTo: format(new Date(), 'yyyy-MM-dd'),
+ hourly: false
+};
+
+@Injectable()
+export class DotCDNStore extends ComponentStore {
+ private readonly dotCdnService = inject(DotCDNService);
+ private readonly httpErrorManager = inject(DotHttpErrorManagerService);
+
+ constructor() {
+ super({
+ chartBandwidthData: { labels: [], datasets: [] },
+ chartRequestsData: { labels: [], datasets: [] },
+ chartCacheHitRateData: { labels: [], datasets: [] },
+ chartErrorData: { labels: [], datasets: [] },
+ cdnDomain: '',
+ statsData: [],
+ isChartLoading: false,
+ isPurgeUrlsLoading: false,
+ isPurgeZoneLoading: false
+ });
+ this.loadStats(DEFAULT_FILTER);
+ }
+
+ readonly vm$ = this.select(
+ ({
+ isChartLoading,
+ chartBandwidthData,
+ chartRequestsData,
+ chartCacheHitRateData,
+ chartErrorData,
+ statsData,
+ cdnDomain
+ }) => ({
+ chartBandwidthData,
+ chartRequestsData,
+ chartCacheHitRateData,
+ chartErrorData,
+ statsData,
+ isChartLoading,
+ cdnDomain
+ })
+ );
+
+ readonly vmPurgeLoaders$ = this.select(({ isPurgeUrlsLoading, isPurgeZoneLoading }) => ({
+ isPurgeUrlsLoading,
+ isPurgeZoneLoading
+ }));
+
+ readonly updateChartState = this.updater(
+ (
+ state,
+ chartData: Omit<
+ DotCDNState,
+ 'isChartLoading' | 'isPurgeUrlsLoading' | 'isPurgeZoneLoading'
+ >
+ ) => ({
+ ...state,
+ chartBandwidthData: chartData.chartBandwidthData,
+ chartRequestsData: chartData.chartRequestsData,
+ chartCacheHitRateData: chartData.chartCacheHitRateData,
+ chartErrorData: chartData.chartErrorData,
+ cdnDomain: chartData.cdnDomain,
+ statsData: chartData.statsData
+ })
+ );
+
+ loadStats = this.effect((filter$: Observable): Observable => {
+ return filter$.pipe(
+ mergeMap((filter: CdnDateFilter) => {
+ this.dispatchLoading({
+ loadingState: LoadingState.LOADING,
+ loader: Loader.CHART
+ });
+
+ const request: StatsRequest = {
+ dateFrom: filter.dateFrom,
+ dateTo: filter.dateTo,
+ hourly: filter.hourly
+ };
+
+ return this.dotCdnService.requestStats(request).pipe(
+ tapResponse({
+ next: (data: DotCDNStats) => {
+ const result = this.getChartStatsData(data, filter.hourly);
+ this.updateChartState(result);
+ },
+ error: (error: HttpErrorResponse) => this.httpErrorManager.handle(error)
+ }),
+ finalize(() =>
+ this.dispatchLoading({
+ loadingState: LoadingState.LOADED,
+ loader: Loader.CHART
+ })
+ )
+ );
+ })
+ );
+ });
+
+ readonly dispatchLoading = this.updater(
+ (state, action: { loadingState: string; loader: string }): DotCDNState => {
+ switch (action.loader) {
+ case Loader.CHART:
+ return {
+ ...state,
+ isChartLoading: action.loadingState === LoadingState.LOADING
+ };
+ case Loader.PURGE_URLS:
+ return {
+ ...state,
+ isPurgeUrlsLoading: action.loadingState === LoadingState.LOADING
+ };
+ case Loader.PURGE_PULL_ZONE:
+ return {
+ ...state,
+ isPurgeZoneLoading: action.loadingState === LoadingState.LOADING
+ };
+ default:
+ return state;
+ }
+ }
+ );
+
+ purgeCDNCache(urls: string[]): Observable {
+ const loading$ = of(
+ this.dispatchLoading({
+ loadingState: LoadingState.LOADING,
+ loader: Loader.PURGE_URLS
+ })
+ );
+
+ return loading$.pipe(
+ switchMap(() =>
+ this.dotCdnService.purgeCache(urls).pipe(
+ finalize(() =>
+ this.dispatchLoading({
+ loadingState: LoadingState.LOADED,
+ loader: Loader.PURGE_URLS
+ })
+ )
+ )
+ )
+ );
+ }
+
+ readonly purgeCDNCacheAll = this.effect((trigger$: Observable) => {
+ return trigger$.pipe(
+ switchMap(() => {
+ this.dispatchLoading({
+ loadingState: LoadingState.LOADING,
+ loader: Loader.PURGE_PULL_ZONE
+ });
+
+ return this.dotCdnService.purgeCacheAll().pipe(
+ tapResponse({
+ next: () => undefined,
+ error: (error: HttpErrorResponse) => this.httpErrorManager.handle(error)
+ }),
+ finalize(() =>
+ this.dispatchLoading({
+ loadingState: LoadingState.LOADED,
+ loader: Loader.PURGE_PULL_ZONE
+ })
+ )
+ );
+ })
+ );
+ });
+
+ private getChartStatsData({ stats }: DotCDNStats, hourly: boolean) {
+ const bandwidthValues = Object.values(stats.bandwidthUsedChart);
+ const { divisor, unit } = DotCDNStore.pickBandwidthUnit(bandwidthValues);
+ const labelFormatter = hourly ? DotCDNStore.formatHourLabel : DotCDNStore.formatDayLabel;
+
+ const chartBandwidthData: ChartData = {
+ labels: Object.keys(stats.bandwidthUsedChart).map(labelFormatter),
+ datasets: [
+ {
+ label: `Bandwidth (${unit})`,
+ data: bandwidthValues.map((v) => (v / divisor).toFixed(2).toString()),
+ borderColor: '#6f5fa3',
+ fill: false
+ }
+ ]
+ };
+
+ const chartRequestsData: ChartData = {
+ labels: Object.keys(stats.requestsServedChart).map(labelFormatter),
+ datasets: [
+ {
+ label: 'Requests Served',
+ data: Object.values(stats.requestsServedChart).map((value: number): string =>
+ value.toString()
+ ),
+ borderColor: '#FFA726',
+ fill: false
+ }
+ ]
+ };
+
+ const cacheHitKeys = Object.keys(stats.cacheHitRateChart || {});
+ const chartCacheHitRateData: ChartData = {
+ labels: cacheHitKeys.map(labelFormatter),
+ datasets: [
+ {
+ label: 'Cache Hit Rate (%)',
+ data: Object.values(stats.cacheHitRateChart || {}).map((v: number): string =>
+ v.toFixed(2)
+ ),
+ borderColor: '#1ea97c',
+ fill: false
+ }
+ ]
+ };
+
+ const errorKeys = Object.keys(stats.error4xxChart || {});
+ const chartErrorData: ChartData = {
+ labels: errorKeys.map(labelFormatter),
+ datasets: [
+ {
+ label: '4xx Errors',
+ data: Object.values(stats.error4xxChart || {}).map((v: number): string =>
+ v.toString()
+ ),
+ borderColor: '#FFA726',
+ fill: false
+ },
+ {
+ label: '5xx Errors',
+ data: Object.values(stats.error5xxChart || {}).map((v: number): string =>
+ v.toString()
+ ),
+ borderColor: '#f65446',
+ fill: false
+ }
+ ]
+ };
+
+ const statsData: DotChartStats[] = [
+ {
+ label: 'Bandwidth Used',
+ value: stats.bandwidthPretty,
+ icon: 'insert_chart_outlined'
+ },
+ {
+ label: 'Requests Served',
+ value: DotCDNStore.formatNumber(stats.totalRequestsServed),
+ icon: 'file_download'
+ },
+ {
+ label: 'Cache Hit Rate',
+ value: `${stats.cacheHitRate.toFixed(2)}%`,
+ icon: 'speed'
+ },
+ {
+ label: 'Avg Origin Response',
+ value:
+ stats.averageOriginResponseTime != null
+ ? `${stats.averageOriginResponseTime}ms`
+ : 'N/A',
+ icon: 'timer'
+ }
+ ];
+
+ return {
+ chartBandwidthData,
+ chartRequestsData,
+ chartCacheHitRateData,
+ chartErrorData,
+ statsData,
+ cdnDomain: stats.cdnDomain
+ };
+ }
+
+ private static pickBandwidthUnit(values: number[]): { divisor: number; unit: string } {
+ const max = values.reduce((a, b) => Math.max(a, b), 0);
+ if (max >= 1e9) {
+ return { divisor: 1e9, unit: 'GB' };
+ } else if (max >= 1e6) {
+ return { divisor: 1e6, unit: 'MB' };
+ } else if (max >= 1e3) {
+ return { divisor: 1e3, unit: 'KB' };
+ }
+
+ return { divisor: 1, unit: 'B' };
+ }
+
+ private static formatNumber(value: number): string {
+ return value.toLocaleString('en-US');
+ }
+
+ /**
+ * Format for hourly data: "Apr 1 2pm"
+ */
+ private static formatHourLabel(key: string): string {
+ const date = new Date(key);
+
+ return (
+ date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
+ ' ' +
+ date.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })
+ );
+ }
+
+ /**
+ * Format for daily data: "Apr 1"
+ */
+ private static formatDayLabel(key: string): string {
+ return new Date(key).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric'
+ });
+ }
+}
diff --git a/core-web/libs/portlets/dot-cdn/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-cdn/src/lib/lib.routes.ts
new file mode 100644
index 000000000000..bb5d80961c2e
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/src/lib/lib.routes.ts
@@ -0,0 +1,10 @@
+import { Route } from '@angular/router';
+
+import { DotCDNComponent } from './dot-cdn.component';
+
+export const dotCdnRoutes: Route[] = [
+ {
+ path: '',
+ component: DotCDNComponent
+ }
+];
diff --git a/core-web/libs/portlets/dot-cdn/tsconfig.json b/core-web/libs/portlets/dot-cdn/tsconfig.json
new file mode 100644
index 000000000000..8a932ccbe168
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "target": "es2022",
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ }
+ ],
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/core-web/libs/portlets/dot-cdn/tsconfig.lib.json b/core-web/libs/portlets/dot-cdn/tsconfig.lib.json
new file mode 100644
index 000000000000..d62a8082de0b
--- /dev/null
+++ b/core-web/libs/portlets/dot-cdn/tsconfig.lib.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json
index 28336d565c77..bb8c83dfde96 100644
--- a/core-web/tsconfig.base.json
+++ b/core-web/tsconfig.base.json
@@ -69,6 +69,7 @@
"@dotcms/portlets/dot-locales/portlet/data-access": [
"libs/portlets/dot-locales/data-access/src/index.ts"
],
+ "@dotcms/portlets/dot-cdn/portlet": ["libs/portlets/dot-cdn/src/index.ts"],
"@dotcms/portlets/dot-plugins/portlet": ["libs/portlets/dot-plugins/src/index.ts"],
"@dotcms/portlets/dot-tags/portlet": ["libs/portlets/dot-tags/src/index.ts"],
"@dotcms/portlets/dot-usage": ["libs/portlets/dot-usage/src/index.ts"],
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/CDNConstants.java b/dotCMS/src/main/java/com/dotcms/cdn/CDNConstants.java
new file mode 100644
index 000000000000..b50015f09648
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/CDNConstants.java
@@ -0,0 +1,13 @@
+package com.dotcms.cdn;
+
+public class CDNConstants {
+
+ public static final String DOT_CDN_APP_KEY = "dotCDN";
+ public static final String DOT_CDN_DOMAIN = "cdnDomain";
+ public static final String DOT_CDN_ZONEID = "cdnZoneId";
+ public static final String DOT_CDN_API_KEY = "cdnApiKey";
+
+ private CDNConstants() {
+ // constants class
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/CDNInterceptor.java b/dotCMS/src/main/java/com/dotcms/cdn/CDNInterceptor.java
new file mode 100644
index 000000000000..38f6185479ec
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/CDNInterceptor.java
@@ -0,0 +1,43 @@
+package com.dotcms.cdn;
+
+import java.util.Collections;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import com.dotcms.filters.interceptor.Result;
+import com.dotcms.filters.interceptor.WebInterceptor;
+import com.dotmarketing.util.Config;
+import com.dotmarketing.util.Logger;
+
+public class CDNInterceptor implements WebInterceptor {
+
+ private static final long serialVersionUID = 1L;
+ private static final List SENSITIVE_HEADERS = List.of(
+ "authorization", "cookie", "proxy-authorization", "set-cookie", "x-csrf-token",
+ "x-xsrf-token");
+
+ @Override
+ public String[] getFilters() {
+ return new String[] {"/*"};
+ }
+
+ @Override
+ public Result intercept(final HttpServletRequest request, final HttpServletResponse response) {
+
+ final boolean recordDotHeaders = request.getParameter("recordDotHeaders") != null
+ && Config.getBooleanProperty("DOT_CDN_DEBUG_HEADERS", false);
+ if (recordDotHeaders) {
+ final List headers = Collections.list(request.getHeaderNames());
+ for (final String header : headers) {
+ Logger.info(this.getClass().getName(),
+ " CDN : " + header + " : " + headerValue(request, header));
+ }
+ }
+
+ return Result.NEXT;
+ }
+
+ private String headerValue(final HttpServletRequest request, final String header) {
+ return SENSITIVE_HEADERS.contains(header.toLowerCase()) ? "[REDACTED]" : request.getHeader(header);
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPI.java b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPI.java
new file mode 100644
index 000000000000..6ef691e4d692
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPI.java
@@ -0,0 +1,85 @@
+package com.dotcms.cdn.api;
+
+import com.dotcms.cdn.CDNConstants;
+import com.dotcms.security.apps.AppSecrets;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.util.UtilMethods;
+import io.vavr.control.Try;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface DotCDNAPI {
+
+ static DotCDNAPI api(Host host) {
+ return new DotCDNAPIImpl(host);
+ }
+
+ static boolean isConfigured(final Host host) {
+ if (host == null || UtilMethods.isNotSet(host.getIdentifier())) {
+ return false;
+ }
+
+ final Optional secrets = Try.of(() -> APILocator.getAppsAPI()
+ .getSecrets(CDNConstants.DOT_CDN_APP_KEY, true, host, APILocator.systemUser()))
+ .getOrElse(Optional.empty());
+ return secrets.isPresent();
+ }
+
+ /**
+ * Logic to get and parse the Stats from bunny to {@link DotCDNStats}
+ * @param dateFromStr The start date of the statistics.
+ * @param dateToStr The end date of the statistics
+ * @return {@link DotCDNStats}
+ */
+ default DotCDNStats getStats(final String dateFromStr, final String dateToStr) {
+ return getStats(dateFromStr, dateToStr, false);
+ }
+
+ /**
+ * Logic to get and parse the Stats from bunny to {@link DotCDNStats}
+ * @param dateFromStr The start date of the statistics.
+ * @param dateToStr The end date of the statistics
+ * @param hourly If true, return hourly granularity instead of daily
+ * @return {@link DotCDNStats}
+ */
+ DotCDNStats getStats(final String dateFromStr, final String dateToStr, final boolean hourly);
+
+ /**
+ * Logic to invalidate the List of urls.
+ * @param urls List of url to invalidate
+ * @return true if all the urls were purged successfully, false if one or more failed.
+ */
+ boolean invalidate(final List urls);
+
+ /**
+ * Invalidate all urls related to the contentlet
+ * @param contentlet {@link Contentlet}
+ * @return boolean
+ */
+ boolean invalidateContentlet(final Contentlet contentlet);
+
+ /**
+ * Invalidate all urls related to the contentlet, in addition can pass extra urls to purge
+ * @param contentlet {@link Contentlet}
+ * @param urlsToPurge {@link List}
+ * @return boolean
+ */
+ boolean invalidateContentlet(final Contentlet contentlet, final List urlsToPurge);
+
+ /**
+ * Invalidate all pages urls related to the contentlet, in addition can pass extra urls to purge
+ * @param contentlet {@link Contentlet}
+ * @param urlsToPurge {@link List}
+ * @return boolean
+ */
+ boolean invalidateRelatedPages(final Contentlet contentlet, final List urlsToPurge);
+
+ /**
+ * Logic to invalidate the entire cache.
+ * @return true if the entire cache was invalidated successfully.
+ */
+ boolean invalidateAll();
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPIImpl.java b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPIImpl.java
new file mode 100644
index 000000000000..1d8ec5d8620b
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNAPIImpl.java
@@ -0,0 +1,408 @@
+package com.dotcms.cdn.api;
+
+import com.dotcms.cdn.CDNConstants;
+import com.dotcms.http.CircuitBreakerUrl;
+import com.dotcms.http.CircuitBreakerUrl.Method;
+import com.dotcms.http.CircuitBreakerUrlBuilder;
+import com.dotcms.rest.RestClientBuilder;
+import com.dotcms.rest.exception.BadRequestException;
+import com.dotcms.rest.exception.NotFoundException;
+import com.dotcms.security.apps.AppSecrets;
+import com.dotcms.uuid.shorty.ShortyIdAPI;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.beans.Identifier;
+import com.dotmarketing.beans.MultiTree;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.UtilMethods;
+import com.dotmarketing.util.json.JSONObject;
+import com.liferay.util.StringPool;
+import io.vavr.control.Try;
+
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public class DotCDNAPIImpl implements DotCDNAPI {
+
+ private final String accessKey;
+ private final long pullZoneId;
+ private final String cdnDomain;
+
+ /**
+ * Method to load the app secrets into the variables.
+ * @param host if is not sent will throw IllegalArgumentException, if sent will try to
+ * find the secrets for it, if there is no secrets for the host will use the ones for the System_Host
+ */
+ DotCDNAPIImpl(final Host host) {
+ if (host == null || UtilMethods.isNotSet(host.getIdentifier())) {
+ Logger.warn(DotCDNAPIImpl.class, "There is no host sent or found");
+ throw new IllegalArgumentException("There is no host sent or found");
+ }
+ final Optional appSecrets = Try.of(() -> APILocator.getAppsAPI()
+ .getSecrets(CDNConstants.DOT_CDN_APP_KEY, true, host, APILocator.systemUser()))
+ .getOrElse(Optional.empty());
+ if (!appSecrets.isPresent()) {
+ Logger.warn(DotCDNAPIImpl.class, "There is no config set, please set it via Apps Tool");
+ throw new NotFoundException("There is no config set, please set it via Apps Tool");
+ }
+ this.accessKey = appSecrets.get().getSecrets().get(CDNConstants.DOT_CDN_API_KEY).getString();
+ this.cdnDomain = appSecrets.get().getSecrets().get(CDNConstants.DOT_CDN_DOMAIN).getString();
+ this.pullZoneId = Long.parseLong(
+ appSecrets.get().getSecrets().get(CDNConstants.DOT_CDN_ZONEID).getString());
+ }
+
+ /**
+ * Url from bunny api to get the stats
+ * @param from The start date of the statistics.
+ * @param to The end date of the statistics
+ * @return url from bunny api with the passed dates and the pullzone
+ */
+ private static final DateTimeFormatter UTC_DATE_FORMATTER =
+ DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneOffset.UTC);
+
+ private String statsUrl(final Instant from, final Instant to, final boolean hourly) {
+ String url = "https://api.bunny.net/statistics?dateFrom=" + UTC_DATE_FORMATTER.format(from)
+ + "&dateTo=" + UTC_DATE_FORMATTER.format(to) + "&pullZone=" + pullZoneId
+ + "&loadErrors=true&loadOriginResponseTimes=true";
+ if (hourly) {
+ url += "&hourly=true";
+ }
+ Logger.debug(DotCDNAPIImpl.class, "Sending URL:" + url);
+ return url;
+ }
+
+ CircuitBreakerUrlBuilder urlBuilder(final String url) {
+ return new CircuitBreakerUrlBuilder()
+ .setHeaders(Map.of("AccessKey", accessKey, "accept", "application/json"))
+ .setTimeout(10000)
+ .setUrl(url)
+ .setMethod(Method.GET);
+ }
+
+ /**
+ * Method to get the response from the url that was hit.
+ * @return if the response was successful the object is returned, if not FAILURE is returned.
+ */
+ private Map getData(CircuitBreakerUrlBuilder url) {
+ final String response = Try.of(() -> url.build().doString())
+ .getOrElse("{\"response\":\"FAILURE\"}");
+ final JSONObject jsonObject = new JSONObject(response);
+ return new HashMap<>(jsonObject);
+ }
+
+ /**
+ * Logic to get and parse the Stats from bunny to {@link DotCDNStats}
+ * @param dateFromStr The start date of the statistics.
+ * @param dateToStr The end date of the statistics
+ * @return {@link DotCDNStats}
+ */
+ @Override
+ public DotCDNStats getStats(final String dateFromStr, final String dateToStr,
+ final boolean hourly) {
+ final ZonedDateTime nowUtc = ZonedDateTime.now(ZoneOffset.UTC)
+ .withSecond(0).withMinute(0).withHour(0);
+ final Instant parsedFrom = Try.of(
+ () -> ZonedDateTime.parse(dateFromStr + "T00:00:00Z").toInstant()).getOrNull();
+ final Instant parsedTo = Try.of(
+ () -> ZonedDateTime.parse(dateToStr + "T00:00:00Z").toInstant()).getOrNull();
+ if (parsedFrom == null) {
+ Logger.debug(this.getClass().getName(),
+ "dateFrom is not sent or does not comply with the format yyyy-MM-dd, using date of 15 days ago");
+ }
+ final Instant dateFrom = parsedFrom != null
+ ? parsedFrom : nowUtc.minusDays(15).toInstant();
+ if (parsedTo == null) {
+ Logger.debug(this.getClass().getName(),
+ "dateTo is not sent or does not comply with the format yyyy-MM-dd, using today's date instead");
+ }
+ final Instant dateTo = parsedTo != null ? parsedTo : nowUtc.toInstant();
+
+ final String statsUrl = statsUrl(dateFrom, dateTo, hourly);
+ final Map data = getData(urlBuilder(statsUrl));
+ if ("FAILURE".equals(data.get("response"))) {
+ final String message = "Failed to get the Stats using the url: " + statsUrl
+ + " , please check the App Configuration";
+ Logger.info(this.getClass().getName(), message);
+ throw new BadRequestException(message);
+ }
+ final DotCDNStats.DotStatsBuilder builder = DotCDNStats.builder()
+ .withCDNDomain(cdnDomain)
+ .withDateFrom(dateFrom.toString())
+ .withDateTo(dateTo.toString())
+ .withCacheHitRate(numberOrDefault(data.get("CacheHitRate"), 0).doubleValue())
+ .withTotalRequestsServed(numberOrDefault(data.get("TotalRequestsServed"), 0).longValue())
+ .withTotalBandwidthUsed(numberOrDefault(data.get("TotalBandwidthUsed"), 0).longValue())
+ .withAverageOriginResponseTime(
+ numberOrDefault(data.get("AverageOriginResponseTime"), 0).intValue());
+
+ builder.withBandwidthUsedChart(mapOrEmpty(data, "BandwidthUsedChart"));
+ builder.withRequestsServedChart(mapOrEmpty(data, "RequestsServedChart"));
+ builder.withCacheHitRateChart(mapOrEmpty(data, "CacheHitRateChart"));
+ builder.withOriginResponseTimeChart(mapOrEmpty(data, "OriginResponseTimeChart"));
+ builder.withError4xxChart(mapOrEmpty(data, "Error4xxChart"));
+ builder.withError5xxChart(mapOrEmpty(data, "Error5xxChart"));
+
+ @SuppressWarnings("unchecked")
+ final Map incomingGeo =
+ (Map) data.getOrDefault("GeoTrafficDistribution",
+ Collections.emptyMap());
+
+ final Map> geoMap = new LinkedHashMap<>();
+ for (final String key : incomingGeo.keySet()) {
+ final String[] keySplit = key.split(":");
+ if (keySplit.length < 2) {
+ continue;
+ }
+ final long traffic = numberOrDefault(incomingGeo.get(key), 0).longValue();
+ final Map geoEntry = geoMap.computeIfAbsent(keySplit[0],
+ e -> new HashMap<>());
+ geoEntry.put(keySplit[1], traffic);
+ }
+ builder.withGeographicDistribution(geoMap);
+
+ return builder.build();
+ }
+
+ @Override
+ public boolean invalidateContentlet(final Contentlet contentlet) {
+ return this.invalidateContentlet(contentlet, Collections.emptyList());
+ }
+
+ @Override
+ public boolean invalidateContentlet(final Contentlet contentlet,
+ final List urlsToPurgeParam) {
+
+ final List urlsToPurge = new ArrayList<>();
+ final List contentletList = new ArrayList<>();
+ contentletList.add(contentlet);
+
+ if (UtilMethods.isSet(urlsToPurgeParam)) {
+ urlsToPurge.addAll(urlsToPurgeParam);
+ }
+
+ if (contentlet.isHTMLPage()) {
+ contentletList.addAll(findPageContent(contentlet));
+ } else {
+ contentletList.addAll(findPagesUsingContent(contentlet));
+ }
+
+ contentletList.forEach(contentletItem -> {
+ Try.of(() -> urlsToPurge.addAll(createUrlsToPurgeForContentlet(contentletItem)))
+ .onFailure(e -> Logger.warn(this.getClass().getName(),
+ "Unable to create urls to purge based on the contentlet: "
+ + e.getMessage()));
+ });
+
+ return this.invalidate(urlsToPurge);
+ }
+
+ @Override
+ public boolean invalidateRelatedPages(final Contentlet contentlet,
+ final List urlsToPurgeParam) {
+
+ final List urlsToPurge = new ArrayList<>();
+
+ if (UtilMethods.isSet(urlsToPurgeParam)) {
+ urlsToPurge.addAll(urlsToPurgeParam);
+ }
+
+ findPagesUsingContent(contentlet).forEach(contentletItem -> {
+ Try.of(() -> urlsToPurge.addAll(createUrlsToPurgeForContentlet(contentletItem)))
+ .onFailure(e -> Logger.warn(this.getClass().getName(),
+ "Unable to create urls to purge based on the contentlet: "
+ + e.getMessage()));
+ });
+
+ return this.invalidate(urlsToPurge);
+ }
+
+ /**
+ * Creates a List of strings (urls) based on the contentlet properties (path, identifier, inode).
+ * @param contentlet contentlet which the actionlet is being fired
+ * @return list of strings urls
+ */
+ private List createUrlsToPurgeForContentlet(final Contentlet contentlet) {
+
+ final List urlsForContentlet = new ArrayList<>();
+
+ final Identifier identifier = Try.of(
+ () -> APILocator.getIdentifierAPI().find(contentlet.getIdentifier())).getOrNull();
+ if (identifier == null) {
+ Logger.debug(this.getClass().getName(),
+ "No identifier found for contentlet: " + contentlet.getIdentifier());
+ return urlsForContentlet;
+ }
+
+ final String path = identifier.getPath();
+ urlsForContentlet.add(path);
+
+ if (path.endsWith("/index")) {
+ urlsForContentlet.add(path.substring(0, path.length() - 5));
+ urlsForContentlet.add(path.substring(0, path.length() - 6));
+ }
+
+ final ShortyIdAPI shorty = APILocator.getShortyAPI();
+ final String inode = contentlet.getInode();
+ final String id = identifier.getId();
+
+ urlsForContentlet.add(buildUrl(id, "*"));
+ urlsForContentlet.add(buildUrl(inode, "*"));
+
+ urlsForContentlet.add(buildUrl(shorty.shortify(id), "*"));
+ urlsForContentlet.add(buildUrl(shorty.shortify(inode), "*"));
+
+ final String urlMap = Try.of(() -> APILocator.getContentletAPI()
+ .getUrlMapForContentlet(contentlet, APILocator.systemUser(), false)).getOrNull();
+ if (urlMap != null) {
+ urlsForContentlet.add(urlMap);
+ }
+
+ urlsForContentlet.removeIf(UtilMethods::isEmpty);
+
+ Logger.debug(this.getClass().getName(),
+ () -> "URLs to purge for contentlet: " + urlsForContentlet);
+
+ return urlsForContentlet;
+ }
+
+ private List findPagesUsingContent(final Contentlet contentlet) {
+ final List trees = Try.of(
+ () -> APILocator.getMultiTreeAPI().getMultiTreesByChild(contentlet.getIdentifier()))
+ .getOrElse(new ArrayList<>());
+
+ return trees.stream()
+ .map(tree -> Try.of(() -> APILocator.getContentletAPI()
+ .findContentletByIdentifierAnyLanguage(tree.getHtmlPage())).getOrNull())
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ }
+
+ private List findPageContent(final Contentlet contentlet) {
+ final List trees = Try.of(
+ () -> APILocator.getMultiTreeAPI().getMultiTreesByPage(contentlet.getIdentifier()))
+ .getOrElse(new ArrayList<>());
+
+ return trees.stream()
+ .map(tree -> Try.of(() -> APILocator.getContentletAPI()
+ .findContentletByIdentifierAnyLanguage(tree.getContentlet())).getOrNull())
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ }
+
+ private String buildUrl(final String... concat) {
+ return "/dA/" + String.join(StringPool.SLASH, concat);
+ }
+
+ /**
+ * Logic to invalidate the List of urls.
+ * @param urls List of url to invalidate
+ * @return true if all the urls were purged successfully, false if one or more failed.
+ */
+ @Override
+ public boolean invalidate(final List urls) {
+ boolean results = true;
+ for (final String url : urls) {
+
+ final String urlToPurge = url.startsWith(cdnDomain) ? url :
+ url.startsWith("/") ? cdnDomain + url :
+ cdnDomain + "/" + url;
+
+ Logger.debug(this.getClass().getName(), "Purging URL: " + urlToPurge);
+
+ final CircuitBreakerUrl.Response response = Try.of(() ->
+ this.urlBuilder(invalidateUrl(urlToPurge))
+ .setMethod(Method.POST)
+ .setThrowWhenError(false)
+ .build()
+ .doResponse()
+ ).getOrNull();
+
+ if (response == null) {
+ Logger.warn(this.getClass().getName(),
+ "Purge failed (no response) for: " + urlToPurge);
+ results = false;
+ } else if (!CircuitBreakerUrl.isSuccessResponse(response.getStatusCode())) {
+ Logger.warn(this.getClass().getName(),
+ "Purge failed for: " + urlToPurge
+ + " - HTTP " + response.getStatusCode()
+ + " - " + response.getResponse());
+ results = false;
+ } else {
+ Logger.debug(this.getClass().getName(), "Purge successful for: " + urlToPurge);
+ }
+ }
+ return results;
+ }
+
+ /**
+ * Logic to invalidate the entire cache.
+ * @return true if the entire cache was invalidated successfully.
+ */
+ @Override
+ public boolean invalidateAll() {
+ try (final Response response = RestClientBuilder.newClient().target(invalidateAllUrl())
+ .request(MediaType.APPLICATION_JSON_TYPE)
+ .header("AccessKey", accessKey)
+ .post(Entity.entity("", MediaType.TEXT_PLAIN))) {
+ Logger.debug(this.getClass().getName(), "Response: " + response);
+ if (response.getStatus() != Response.Status.NO_CONTENT.getStatusCode()) {
+ final String message = "Failed to Purge the entire Cache: " + invalidateAllUrl()
+ + " , please check the App Configuration";
+ Logger.info(this.getClass().getName(), message);
+ throw new BadRequestException(message);
+ }
+ return true;
+ }
+ }
+
+ private String invalidateAllUrl() {
+ return "https://api.bunny.net/pullzone/" + this.pullZoneId + "/purgeCache";
+ }
+
+ private String invalidateUrl(String url) {
+ try {
+ return "https://api.bunny.net/purge?url="
+ + URLEncoder.encode(url, java.nio.charset.StandardCharsets.UTF_8.name());
+ } catch (UnsupportedEncodingException e) {
+ throw new IllegalStateException("UTF-8 encoding is not available", e);
+ }
+ }
+
+ String getCdnDomain() {
+ return cdnDomain;
+ }
+
+ private static Number numberOrDefault(final Object value, final Number defaultValue) {
+ if (value instanceof Number) {
+ return (Number) value;
+ }
+ return defaultValue;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map mapOrEmpty(final Map data,
+ final String key) {
+ final Object value = data.get(key);
+ if (value instanceof Map) {
+ return (Map) value;
+ }
+ return Collections.emptyMap();
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNStats.java b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNStats.java
new file mode 100644
index 000000000000..c0180ec0491f
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/api/DotCDNStats.java
@@ -0,0 +1,253 @@
+package com.dotcms.cdn.api;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+import java.util.Collections;
+import java.util.Map;
+import javax.annotation.Nonnull;
+
+@JsonDeserialize(builder = DotCDNStats.DotStatsBuilder.class)
+public class DotCDNStats {
+
+ private final String cdnDomain;
+ private final String dateFrom;
+ private final String dateTo;
+ private final double cacheHitRate;
+ private final long totalBandwidthUsed;
+ private final long totalRequestsServed;
+ private final String bandwidthPretty;
+ private final int averageOriginResponseTime;
+ private final Map> geographicDistribution;
+ private final Map bandwidthUsedChart;
+ private final Map requestsServedChart;
+ private final Map cacheHitRateChart;
+ private final Map originResponseTimeChart;
+ private final Map error4xxChart;
+ private final Map error5xxChart;
+
+ private DotCDNStats(DotStatsBuilder builder) {
+ this.cdnDomain = builder.cdnDomain;
+ this.dateFrom = builder.dateFrom.toString();
+ this.dateTo = builder.dateTo.toString();
+ this.cacheHitRate = builder.cacheHitRate;
+ this.totalBandwidthUsed = builder.totalBandwidthUsed;
+ this.totalRequestsServed = builder.totalRequestsServed;
+ this.averageOriginResponseTime = builder.averageOriginResponseTime;
+ this.geographicDistribution = builder.geographicDistribution;
+ this.bandwidthPretty = prettyByteify(builder.totalBandwidthUsed);
+ this.bandwidthUsedChart = builder.bandwidthUsedChart;
+ this.requestsServedChart = builder.requestsServedChart;
+ this.cacheHitRateChart = builder.cacheHitRateChart;
+ this.originResponseTimeChart = builder.originResponseTimeChart;
+ this.error4xxChart = builder.error4xxChart;
+ this.error5xxChart = builder.error5xxChart;
+ }
+
+ public static DotStatsBuilder builder() {
+ return new DotStatsBuilder();
+ }
+
+ public static DotStatsBuilder from(final DotCDNStats dotCDNStats) {
+ return new DotStatsBuilder(dotCDNStats);
+ }
+
+ public String getCdnDomain() {
+ return cdnDomain;
+ }
+
+ public String getDateFrom() {
+ return dateFrom;
+ }
+
+ public String getDateTo() {
+ return dateTo;
+ }
+
+ public double getCacheHitRate() {
+ return cacheHitRate;
+ }
+
+ public long getTotalBandwidthUsed() {
+ return totalBandwidthUsed;
+ }
+
+ public long getTotalRequestsServed() {
+ return totalRequestsServed;
+ }
+
+ public String getBandwidthPretty() {
+ return bandwidthPretty;
+ }
+
+ public int getAverageOriginResponseTime() {
+ return averageOriginResponseTime;
+ }
+
+ public Map> getGeographicDistribution() {
+ return geographicDistribution;
+ }
+
+ public Map getBandwidthUsedChart() {
+ return bandwidthUsedChart;
+ }
+
+ public Map getRequestsServedChart() {
+ return requestsServedChart;
+ }
+
+ public Map getCacheHitRateChart() {
+ return cacheHitRateChart;
+ }
+
+ public Map getOriginResponseTimeChart() {
+ return originResponseTimeChart;
+ }
+
+ public Map getError4xxChart() {
+ return error4xxChart;
+ }
+
+ public Map getError5xxChart() {
+ return error5xxChart;
+ }
+
+ public static final class DotStatsBuilder {
+
+ private String cdnDomain;
+ private String dateFrom;
+ private String dateTo;
+ private double cacheHitRate;
+ private long totalBandwidthUsed;
+ private long totalRequestsServed;
+ private int averageOriginResponseTime;
+ private Map> geographicDistribution = Collections.emptyMap();
+ private Map bandwidthUsedChart;
+ private Map requestsServedChart;
+ private Map cacheHitRateChart = Collections.emptyMap();
+ private Map originResponseTimeChart = Collections.emptyMap();
+ private Map error4xxChart = Collections.emptyMap();
+ private Map error5xxChart = Collections.emptyMap();
+
+ private DotStatsBuilder() {}
+
+ private DotStatsBuilder(final DotCDNStats dotCDNStats) {
+ this.cdnDomain = dotCDNStats.cdnDomain;
+ this.dateFrom = dotCDNStats.dateFrom;
+ this.dateTo = dotCDNStats.dateTo;
+ this.cacheHitRate = dotCDNStats.cacheHitRate;
+ this.totalBandwidthUsed = dotCDNStats.totalBandwidthUsed;
+ this.totalRequestsServed = dotCDNStats.totalRequestsServed;
+ this.averageOriginResponseTime = dotCDNStats.averageOriginResponseTime;
+ this.geographicDistribution = dotCDNStats.geographicDistribution;
+ this.bandwidthUsedChart = dotCDNStats.bandwidthUsedChart;
+ this.requestsServedChart = dotCDNStats.requestsServedChart;
+ this.cacheHitRateChart = dotCDNStats.cacheHitRateChart;
+ this.originResponseTimeChart = dotCDNStats.originResponseTimeChart;
+ this.error4xxChart = dotCDNStats.error4xxChart;
+ this.error5xxChart = dotCDNStats.error5xxChart;
+ }
+
+ public DotStatsBuilder withCDNDomain(@Nonnull final String cdnDomain) {
+ this.cdnDomain = cdnDomain;
+ return this;
+ }
+
+ public DotStatsBuilder withDateFrom(@Nonnull final String dateFrom) {
+ this.dateFrom = dateFrom;
+ return this;
+ }
+
+ public DotStatsBuilder withDateTo(@Nonnull final String dateTo) {
+ this.dateTo = dateTo;
+ return this;
+ }
+
+ public DotStatsBuilder withCacheHitRate(final double cacheHitRate) {
+ this.cacheHitRate = cacheHitRate;
+ return this;
+ }
+
+ public DotStatsBuilder withTotalBandwidthUsed(final long totalBandwidthUsed) {
+ this.totalBandwidthUsed = totalBandwidthUsed;
+ return this;
+ }
+
+ public DotStatsBuilder withTotalRequestsServed(final long totalRequestsServed) {
+ this.totalRequestsServed = totalRequestsServed;
+ return this;
+ }
+
+ public DotStatsBuilder withAverageOriginResponseTime(final int averageOriginResponseTime) {
+ this.averageOriginResponseTime = averageOriginResponseTime;
+ return this;
+ }
+
+ public DotStatsBuilder withGeographicDistribution(
+ @Nonnull final Map> geographicDistribution) {
+ this.geographicDistribution = geographicDistribution;
+ return this;
+ }
+
+ public DotStatsBuilder withBandwidthUsedChart(
+ @Nonnull final Map bandWidthUsed) {
+ this.bandwidthUsedChart = bandWidthUsed;
+ return this;
+ }
+
+ public DotStatsBuilder withRequestsServedChart(
+ @Nonnull final Map requestsServedChart) {
+ this.requestsServedChart = requestsServedChart;
+ return this;
+ }
+
+ public DotStatsBuilder withCacheHitRateChart(
+ @Nonnull final Map cacheHitRateChart) {
+ this.cacheHitRateChart = cacheHitRateChart;
+ return this;
+ }
+
+ public DotStatsBuilder withOriginResponseTimeChart(
+ @Nonnull final Map originResponseTimeChart) {
+ this.originResponseTimeChart = originResponseTimeChart;
+ return this;
+ }
+
+ public DotStatsBuilder withError4xxChart(
+ @Nonnull final Map error4xxChart) {
+ this.error4xxChart = error4xxChart;
+ return this;
+ }
+
+ public DotStatsBuilder withError5xxChart(
+ @Nonnull final Map error5xxChart) {
+ this.error5xxChart = error5xxChart;
+ return this;
+ }
+
+ public DotCDNStats build() {
+ return new DotCDNStats(this);
+ }
+ }
+
+ private static final int BYTES_PER_DECIMAL_UNIT = 1000;
+
+ private String prettyByteify(final long memory) {
+ final NumberFormat numberFormat = new DecimalFormat("#.00");
+ final double x = memory;
+ if (x > (BYTES_PER_DECIMAL_UNIT * BYTES_PER_DECIMAL_UNIT * BYTES_PER_DECIMAL_UNIT)) {
+ return numberFormat.format(x / (BYTES_PER_DECIMAL_UNIT * BYTES_PER_DECIMAL_UNIT
+ * BYTES_PER_DECIMAL_UNIT)) + " GB";
+ } else if (x > (BYTES_PER_DECIMAL_UNIT * BYTES_PER_DECIMAL_UNIT)) {
+ return numberFormat.format(x / (BYTES_PER_DECIMAL_UNIT * BYTES_PER_DECIMAL_UNIT))
+ + " MB";
+ } else if (x > BYTES_PER_DECIMAL_UNIT) {
+ return numberFormat.format(x / BYTES_PER_DECIMAL_UNIT) + " KB";
+ } else if (x > 1) {
+ return numberFormat.format(x) + " B";
+ } else {
+ return "0 b";
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/pushpublish/receiver/event/PushPublishOnReceiverEndSubscriber.java b/dotCMS/src/main/java/com/dotcms/cdn/pushpublish/receiver/event/PushPublishOnReceiverEndSubscriber.java
new file mode 100644
index 000000000000..05cdd565fdd8
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/pushpublish/receiver/event/PushPublishOnReceiverEndSubscriber.java
@@ -0,0 +1,140 @@
+package com.dotcms.cdn.pushpublish.receiver.event;
+
+import com.dotcms.cdn.api.DotCDNAPI;
+import com.dotcms.publisher.business.PublishQueueElement;
+import com.dotcms.system.event.local.model.Subscriber;
+import com.dotcms.system.event.local.type.pushpublish.receiver.PushPublishEndOnReceiverEvent;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.portlets.contentlet.business.ContentletAPI;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.util.Config;
+import com.dotmarketing.util.Logger;
+import com.liferay.portal.model.User;
+import io.vavr.control.Try;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * This subscriber is in charge of invalidating CDN cache when push-publish completes on receiver.
+ */
+public class PushPublishOnReceiverEndSubscriber {
+
+ private static final boolean LIVE = false;
+ private static final boolean RESPECT_FRONTEND_ROLES = false;
+
+ private final User user = APILocator.systemUser();
+
+ @Subscriber
+ public void notify(PushPublishEndOnReceiverEvent event) {
+
+ final ContentletAPI contentletAPI = APILocator.getContentletAPI();
+ final List publishQueueElements =
+ event.getPublishQueueElements().stream()
+ .filter(pqe -> "contentlet".equals(pqe.getType()))
+ .collect(Collectors.toList());
+ final int minimumNumberToPurgeAll =
+ Config.getIntProperty("DOT_CDN_MIN_NUM_TO_PURGE_ALL", 250);
+
+ if (publishQueueElements.size() > minimumNumberToPurgeAll) {
+ this.invalidateAllSites(contentletAPI, publishQueueElements);
+ } else {
+ invalidateContentlets(contentletAPI, publishQueueElements);
+ }
+ }
+
+ private void invalidateContentlets(final ContentletAPI contentletAPI,
+ final List publishQueueElements) {
+
+ final Map dotCDNAPIMap = new HashMap<>();
+ final Set unconfiguredHosts = new HashSet<>();
+ Logger.info(this, "Purging selective Contentlets on CDN");
+
+ for (final PublishQueueElement publishQueueElement : publishQueueElements) {
+
+ final String identifier = publishQueueElement.getAsset();
+ final long languageId = publishQueueElement.getLanguageId();
+ final Contentlet contentlet = Try.of(() -> contentletAPI
+ .findContentletByIdentifier(identifier, LIVE, languageId, user,
+ RESPECT_FRONTEND_ROLES)).getOrNull();
+
+ if (null == contentlet) {
+ Logger.debug(this, () -> "Cannot invalidate contentlet: "
+ + identifier + " — not found");
+ continue;
+ }
+
+ final String hostId = contentlet.getHost();
+ if (unconfiguredHosts.contains(hostId)) {
+ continue;
+ }
+
+ final Host site = Try.of(
+ () -> APILocator.getHostAPI().find(hostId, user, RESPECT_FRONTEND_ROLES))
+ .getOrNull();
+ if (null == site) {
+ Logger.debug(this, () -> "Cannot invalidate contentlet: "
+ + identifier + " — host not found: " + hostId);
+ continue;
+ }
+
+ if (!DotCDNAPI.isConfigured(site)) {
+ unconfiguredHosts.add(hostId);
+ Logger.debug(this, () -> "dotCDN not configured for host: "
+ + site.getHostname() + ", skipping");
+ continue;
+ }
+
+ final DotCDNAPI cdnApi =
+ dotCDNAPIMap.computeIfAbsent(hostId, k -> DotCDNAPI.api(site));
+ Logger.debug(this, () -> "Invalidating contentlet: "
+ + contentlet.getIdentifier());
+ Try.run(() -> cdnApi.invalidateContentlet(contentlet))
+ .onFailure(e -> Logger.warn(this,
+ "CDN purge failed for contentlet: " + contentlet.getIdentifier()
+ + " - " + e.getMessage()));
+ }
+ }
+
+ private void invalidateAllSites(final ContentletAPI contentletAPI,
+ final List publishQueueElements) {
+
+ final Map hostMap = new HashMap<>();
+ for (final PublishQueueElement publishQueueElement : publishQueueElements) {
+
+ final String identifier = publishQueueElement.getAsset();
+ final long languageId = publishQueueElement.getLanguageId();
+ final Contentlet contentlet = Try.of(() -> contentletAPI
+ .findContentletByIdentifier(identifier, LIVE, languageId, user,
+ RESPECT_FRONTEND_ROLES)).getOrNull();
+ if (null != contentlet) {
+ final Host site = Try.of(() -> APILocator.getHostAPI()
+ .find(contentlet.getHost(), user, RESPECT_FRONTEND_ROLES)).getOrNull();
+ if (null != site) {
+ hostMap.put(contentlet.getHost(), site);
+ }
+ }
+ }
+
+ for (final Host site : hostMap.values()) {
+ if (!DotCDNAPI.isConfigured(site)) {
+ Logger.debug(this, () -> "dotCDN not configured for host: "
+ + site.getHostname() + ", skipping purge-all");
+ continue;
+ }
+
+ final DotCDNAPI cdnApi = DotCDNAPI.api(site);
+ Logger.info(this, "Purging all CDN, host: " + site.getHostname());
+ Try.of(cdnApi::invalidateAll)
+ .onSuccess(resultInvalidate -> Logger.info(this, "Purge result: "
+ + resultInvalidate + " host: " + site.getHostname()))
+ .onFailure(e -> Logger.warn(this, "Unable to purge all CDN, host: "
+ + site.getHostname() + " - " + e.getMessage()));
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/rest/DotCDNResource.java b/dotCMS/src/main/java/com/dotcms/cdn/rest/DotCDNResource.java
new file mode 100644
index 000000000000..0fb5c13c3caa
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/rest/DotCDNResource.java
@@ -0,0 +1,249 @@
+package com.dotcms.cdn.rest;
+
+import com.dotcms.cdn.api.DotCDNAPI;
+import com.dotcms.cdn.api.DotCDNStats;
+import com.dotcms.rest.ResponseEntityView;
+import com.dotcms.rest.WebResource;
+import com.dotcms.rest.annotation.NoCache;
+import com.dotcms.rest.exception.BadRequestException;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.util.Config;
+import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.UtilMethods;
+import com.dotmarketing.util.WebKeys;
+import com.liferay.portal.model.User;
+import io.vavr.Lazy;
+import io.vavr.control.Try;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+@Path("/v1/dotcdn")
+public class DotCDNResource {
+
+ private static final int DEFAULT_MAX_PURGE_URLS = 1000;
+ private static final int MAX_PURGE_URL_LENGTH = 2048;
+
+ /**
+ * This endpoint is to get the statistics from the CDN.
+ * We get the stats of a range of dates.
+ *
+ * The hostId property is to get the AppSecret Configuration. If the hostId is not send will
+ * try to get it from the session.
+ *
+ * @param dateFromStr date from we should get the stats, format yyyy-MM-dd
+ * @param dateToStr date until we should get the stats, format: yyyy-MM-dd
+ * @param hostId Id of the host which App config we should get.
+ * @return stats response
+ */
+ @GET
+ @Path("/stats")
+ @NoCache
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Get CDN statistics",
+ responses = @ApiResponse(
+ responseCode = "200",
+ description = "CDN statistics for the requested date range",
+ content = @Content(
+ schema = @Schema(implementation = ResponseEntityDotCDNStatsView.class))))
+ public final Response getStats(
+ @Context final HttpServletRequest request,
+ @Context final HttpServletResponse response,
+ @QueryParam("dateFrom") final String dateFromStr,
+ @QueryParam("dateTo") final String dateToStr,
+ @QueryParam("hostId") final String hostId,
+ @QueryParam("hourly") final boolean hourly) {
+
+ final User user = new WebResource.InitBuilder(request, response)
+ .rejectWhenNoUser(true).requiredBackendUser(true)
+ .requiredPortlet("dotCDN").init().getUser();
+
+ final Lazy lazyCurrentHost = Lazy.of(() -> Try.of(
+ () -> Host.class.cast(request.getSession().getAttribute(WebKeys.CURRENT_HOST)))
+ .getOrNull());
+ final Host host = Try.of(() -> APILocator.getHostAPI()
+ .find(hostId, user, false)).getOrElse(lazyCurrentHost.get());
+
+ if (!DotCDNAPI.isConfigured(host)) {
+ throw new BadRequestException(
+ "dotCDN is not configured for this host. Please configure it via the Apps tool.");
+ }
+
+ return Response.ok(new ResponseEntityDotCDNStatsView(
+ new DotCDNStatsResponse(DotCDNAPI.api(host).getStats(dateFromStr, dateToStr, hourly))))
+ .build();
+ }
+
+ /**
+ * This endpoint is to purgeCache of the CDN.
+ * You can purge the entire cache by setting the invalidateAll to true
+ * Or you can purge specific urls by sending them in a Array.
+ *
+ * The hostId property is to get the AppSecret Configuration. If the hostId is not send will
+ * try to get it from the session.
+ *
+ * @param invalidationForm request body
+ * @return a message if the purged was successful.
+ */
+ @DELETE
+ @Path("/")
+ @NoCache
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Purge CDN cache",
+ responses = @ApiResponse(
+ responseCode = "200",
+ description = "CDN purge result",
+ content = @Content(
+ schema = @Schema(implementation = ResponseEntityDotCDNPurgeView.class))))
+ public final Response purgeCache(@Context final HttpServletRequest request,
+ @Context final HttpServletResponse response,
+ final InvalidationForm invalidationForm) {
+
+ final User user = new WebResource.InitBuilder(request, response)
+ .rejectWhenNoUser(true).requiredBackendUser(true)
+ .requiredPortlet("dotCDN").init().getUser();
+
+ final Lazy lazyCurrentHost = Lazy.of(() -> Try.of(
+ () -> Host.class.cast(request.getSession().getAttribute(WebKeys.CURRENT_HOST)))
+ .getOrNull());
+ if (invalidationForm == null) {
+ throw new BadRequestException("Invalidation form is required");
+ }
+
+ final Host host = Try.of(() -> APILocator.getHostAPI()
+ .find(invalidationForm.getHostId(), user, false))
+ .getOrElse(lazyCurrentHost.get());
+
+ if (!DotCDNAPI.isConfigured(host)) {
+ throw new BadRequestException(
+ "dotCDN is not configured for this host. Please configure it via the Apps tool.");
+ }
+
+ final DotCDNAPI cdnApi = DotCDNAPI.api(host);
+
+ if (invalidationForm.isInvalidateAll()) {
+ Logger.info(this.getClass().getName(),
+ "User:" + user.getUserId() + " purging entire cache");
+ return Response.ok(new ResponseEntityDotCDNPurgeView(
+ new DotCDNPurgeResponse(cdnApi.invalidateAll(), true))).build();
+ }
+
+ final List urls = validateUrls(invalidationForm.getUrls());
+ return Response.ok(new ResponseEntityDotCDNPurgeView(
+ new DotCDNPurgeResponse(cdnApi.invalidate(urls), false))).build();
+ }
+
+ private List validateUrls(final List urls) {
+ if (!UtilMethods.isSet(urls)) {
+ throw new BadRequestException("At least one URL is required");
+ }
+
+ final int maxPurgeUrls = Config.getIntProperty(
+ "DOT_CDN_MAX_PURGE_URLS", DEFAULT_MAX_PURGE_URLS);
+ if (urls.size() > maxPurgeUrls) {
+ throw new BadRequestException("A maximum of " + maxPurgeUrls + " URLs can be purged");
+ }
+
+ final List normalizedUrls = urls.stream()
+ .filter(UtilMethods::isSet)
+ .map(String::trim)
+ .collect(Collectors.toList());
+
+ if (normalizedUrls.size() != urls.size()) {
+ throw new BadRequestException("Purge URLs cannot be blank");
+ }
+
+ for (final String url : normalizedUrls) {
+ if (!isValidPurgeUrl(url)) {
+ throw new BadRequestException("Invalid purge URL: " + url);
+ }
+ }
+
+ return normalizedUrls;
+ }
+
+ private boolean isValidPurgeUrl(final String url) {
+ if (url.length() > MAX_PURGE_URL_LENGTH || containsControlCharacter(url)) {
+ return false;
+ }
+
+ return url.startsWith("/");
+ }
+
+ private boolean containsControlCharacter(final String url) {
+ for (int i = 0; i < url.length(); i++) {
+ if (Character.isISOControl(url.charAt(i))) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Schema(description = "CDN statistics response")
+ public static class DotCDNStatsResponse {
+
+ private final DotCDNStats stats;
+
+ public DotCDNStatsResponse(final DotCDNStats stats) {
+ this.stats = stats;
+ }
+
+ public DotCDNStats getStats() {
+ return stats;
+ }
+ }
+
+ @Schema(description = "CDN purge response")
+ public static class DotCDNPurgeResponse {
+
+ private final boolean success;
+ private final boolean invalidateAll;
+
+ public DotCDNPurgeResponse(final boolean success, final boolean invalidateAll) {
+ this.success = success;
+ this.invalidateAll = invalidateAll;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public boolean isInvalidateAll() {
+ return invalidateAll;
+ }
+ }
+
+ public static class ResponseEntityDotCDNStatsView
+ extends ResponseEntityView {
+
+ public ResponseEntityDotCDNStatsView(final DotCDNStatsResponse entity) {
+ super(entity);
+ }
+ }
+
+ public static class ResponseEntityDotCDNPurgeView
+ extends ResponseEntityView {
+
+ public ResponseEntityDotCDNPurgeView(final DotCDNPurgeResponse entity) {
+ super(entity);
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/rest/InvalidationForm.java b/dotCMS/src/main/java/com/dotcms/cdn/rest/InvalidationForm.java
new file mode 100644
index 000000000000..fc13a9d1b5ad
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/rest/InvalidationForm.java
@@ -0,0 +1,64 @@
+package com.dotcms.cdn.rest;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@JsonDeserialize(builder = InvalidationForm.Builder.class)
+public class InvalidationForm {
+
+ private final List urls;
+ private final boolean invalidateAll;
+ private final String hostId;
+
+ private InvalidationForm(Builder builder) {
+ urls = Collections.unmodifiableList(new ArrayList<>(builder.urls));
+ invalidateAll = builder.invalidateAll;
+ hostId = builder.hostId;
+ }
+
+ public boolean isInvalidateAll() {
+ return invalidateAll;
+ }
+
+ public String getHostId() {
+ return hostId;
+ }
+
+ public List getUrls() {
+ return urls;
+ }
+
+ public static final class Builder {
+
+ @JsonProperty
+ private List urls = new ArrayList<>();
+
+ @JsonProperty
+ private String hostId;
+
+ @JsonProperty
+ private boolean invalidateAll = false;
+
+ public Builder urls(final List urls) {
+ this.urls = urls == null ? new ArrayList<>() : new ArrayList<>(urls);
+ return this;
+ }
+
+ public Builder invalidateAll(final boolean invalidateAll) {
+ this.invalidateAll = invalidateAll;
+ return this;
+ }
+
+ public Builder hostId(final String hostId) {
+ this.hostId = hostId;
+ return this;
+ }
+
+ public InvalidationForm build() {
+ return new InvalidationForm(this);
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNTool.java b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNTool.java
new file mode 100644
index 000000000000..36953029d0fb
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNTool.java
@@ -0,0 +1,45 @@
+package com.dotcms.cdn.viewtool;
+
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.velocity.tools.view.context.ViewContext;
+import org.apache.velocity.tools.view.tools.ViewTool;
+import com.dotcms.cdn.CDNConstants;
+import com.dotcms.security.apps.AppSecrets;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.business.web.WebAPILocator;
+import io.vavr.Lazy;
+import io.vavr.control.Try;
+
+public class DotCDNTool implements ViewTool {
+
+ private HttpServletRequest request;
+ private Host host;
+
+ private final Lazy> appSecrets = Lazy.of(() ->
+ Try.of(() -> APILocator.getAppsAPI()
+ .getSecrets(CDNConstants.DOT_CDN_APP_KEY, true, host, APILocator.systemUser()))
+ .getOrElse(Optional.empty()));
+
+ @Override
+ public void init(Object initData) {
+ this.request = ((ViewContext) initData).getRequest();
+ this.host = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(this.request);
+ }
+
+ /**
+ * Creates the full url where the file lives in the cdn.
+ *
+ * @return the cdn domain + url, that is where the file lives
+ */
+ public String cdnify(final String fileUrl) {
+ if (!appSecrets.get().isPresent() || fileUrl == null
+ || fileUrl.contains("//") || !fileUrl.startsWith("/")) {
+ return fileUrl;
+ }
+
+ return appSecrets.get().get().getSecrets()
+ .get(CDNConstants.DOT_CDN_DOMAIN).getString() + fileUrl;
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNToolInfo.java b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNToolInfo.java
new file mode 100644
index 000000000000..7cbb72ccb4e2
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/viewtool/DotCDNToolInfo.java
@@ -0,0 +1,30 @@
+package com.dotcms.cdn.viewtool;
+
+import org.apache.velocity.tools.view.context.ViewContext;
+import org.apache.velocity.tools.view.servlet.ServletToolInfo;
+
+public class DotCDNToolInfo extends ServletToolInfo {
+
+ @Override
+ public String getKey() {
+ return "dotcdn";
+ }
+
+ @Override
+ public String getScope() {
+ return ViewContext.REQUEST;
+ }
+
+ @Override
+ public String getClassname() {
+ return DotCDNTool.class.getName();
+ }
+
+ @Override
+ public Object getInstance(Object initData) {
+ DotCDNTool viewTool = new DotCDNTool();
+ viewTool.init(initData);
+ setScope(ViewContext.REQUEST);
+ return viewTool;
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/cdn/workflow/DotCDNInvalidateActionlet.java b/dotCMS/src/main/java/com/dotcms/cdn/workflow/DotCDNInvalidateActionlet.java
new file mode 100644
index 000000000000..cd17a39290ce
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/cdn/workflow/DotCDNInvalidateActionlet.java
@@ -0,0 +1,112 @@
+package com.dotcms.cdn.workflow;
+
+import com.dotcms.cdn.api.DotCDNAPI;
+import com.dotcms.concurrent.DotConcurrentFactory;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.portlets.workflows.actionlet.WorkFlowActionlet;
+import com.dotmarketing.portlets.workflows.model.WorkflowActionClassParameter;
+import com.dotmarketing.portlets.workflows.model.WorkflowActionFailureException;
+import com.dotmarketing.portlets.workflows.model.WorkflowActionletParameter;
+import com.dotmarketing.portlets.workflows.model.WorkflowProcessor;
+import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.UtilMethods;
+import io.vavr.control.Try;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.StringTokenizer;
+
+public class DotCDNInvalidateActionlet extends WorkFlowActionlet {
+
+ private static final long serialVersionUID = 1L;
+ private static final String PURGE_CONTENTLET_PARAM = "purgeContentlet";
+ private static final String PURGE_URL_PARAM = "purgeUrl";
+
+ @Override
+ public List getParameters() {
+ final List params = new ArrayList<>();
+ params.add(new WorkflowActionletParameter(PURGE_CONTENTLET_PARAM,
+ "Purge Contentlet", "true", false));
+ params.add(new WorkflowActionletParameter(PURGE_URL_PARAM,
+ "Additional Url(s) to Purge", "", false));
+ return params;
+ }
+
+ @Override
+ public String getName() {
+ return "dotCDN Purge";
+ }
+
+ @Override
+ public String getHowTo() {
+ return "URL(s) to purge: can be set to specific url(s) to be purged. "
+ + "Use comma (,) as a delimiter."
+ + "Purge Contentlet: It creates a list of patterns that should "
+ + "be purged using the $contentlet object."
+ + "e.g. /dA/$contentlet.identifier/* or /dA/$contentlet.shortyInode/*";
+ }
+
+ @Override
+ public void executeAction(WorkflowProcessor processor,
+ Map params)
+ throws WorkflowActionFailureException {
+
+ final Contentlet contentlet = processor.getContentlet();
+ final String purgeUrls = params.get(PURGE_URL_PARAM).getValue();
+ final boolean isPurgeContentlet = Try.of(
+ () -> Boolean.parseBoolean(
+ params.get(PURGE_CONTENTLET_PARAM).getValue().trim()))
+ .getOrElse(true);
+ final Host host = Try.of(() -> APILocator.getHostAPI()
+ .find(contentlet.getHost(), APILocator.systemUser(), false))
+ .getOrNull();
+ if (host == null) {
+ Logger.warn(this.getClass().getName(), "Contentlet Host is Null");
+ return;
+ }
+ if (!DotCDNAPI.isConfigured(host)) {
+ Logger.debug(this.getClass().getName(),
+ "dotCDN not configured for host: " + host.getHostname() + ", skipping");
+ return;
+ }
+ final DotCDNAPI cdnApi = DotCDNAPI.api(host);
+ final List urlsToPurge = new ArrayList<>();
+
+ if (!UtilMethods.isEmpty(purgeUrls)) {
+ urlsToPurge.addAll(parseUrlsParam(purgeUrls));
+ }
+
+ Logger.info(this, "PurgeContentlet: " + isPurgeContentlet);
+ if (isPurgeContentlet) {
+ DotConcurrentFactory.getInstance().getSubmitter().submit(() -> {
+ Try.of(() -> cdnApi.invalidateContentlet(contentlet, urlsToPurge))
+ .onFailure(e -> Logger.warn(DotCDNInvalidateActionlet.class,
+ "CDN purge failed for contentlet: "
+ + contentlet.getIdentifier() + " - " + e.getMessage()));
+ });
+ } else {
+ DotConcurrentFactory.getInstance().getSubmitter().submit(() -> {
+ Try.of(() -> cdnApi.invalidateRelatedPages(contentlet, urlsToPurge))
+ .onFailure(e -> Logger.warn(DotCDNInvalidateActionlet.class,
+ "CDN purge (related pages) failed for contentlet: "
+ + contentlet.getIdentifier() + " - " + e.getMessage()));
+ });
+ }
+ }
+
+ private List parseUrlsParam(final String urlsParam) {
+ final List urls = new ArrayList<>();
+ if (urlsParam == null) {
+ return urls;
+ }
+ final StringTokenizer st = new StringTokenizer(urlsParam, ",");
+ while (st.hasMoreTokens()) {
+ urls.add(st.nextToken().trim());
+ }
+ urls.removeIf(UtilMethods::isEmpty);
+ return urls;
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java
index f44ca0470f7f..c8e020315264 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java
@@ -16,8 +16,10 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import javax.ws.rs.ApplicationPath;
+import javax.ws.rs.Path;
import org.glassfish.jersey.media.multipart.MultiPartFeature;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.server.ServerProperties;
@@ -130,6 +132,14 @@ public class DotRestApplication extends ResourceConfig {
private static final Lazy ENABLE_TELEMETRY_FROM_CORE = Lazy.of(() ->
Config.getBooleanProperty(FeatureFlagName.FEATURE_FLAG_TELEMETRY_CORE_ENABLED, true));
+ /**
+ * @Path values of REST resources registered via core package scanning.
+ * Used to reject OSGI plugins that would conflict with migrated-to-core resources.
+ */
+ private static final Set CORE_REST_PATHS = Set.of(
+ "/v1/dotcdn"
+ );
+
public DotRestApplication() {
// Include the rest of the application configuration
@@ -143,6 +153,7 @@ private void configureApplication() {
"com.dotcms.contenttype.model.field",
"com.dotcms.rendering.js",
"com.dotcms.ai.rest",
+ "com.dotcms.cdn.rest",
"com.dotcms.health",
"io.swagger.v3.jaxrs2"));
@@ -189,6 +200,18 @@ public static synchronized void addClass(final Class> clazz) {
return;
}
+ // Check if a core resource already serves the same @Path — prevents OSGI plugins
+ // from conflicting with resources that have been migrated into core
+ final Path pluginPath = clazz.getAnnotation(Path.class);
+ if (pluginPath != null && CORE_REST_PATHS.contains(pluginPath.value())) {
+ Logger.warn(DotRestApplication.class,
+ "Plugin REST resource at @Path(\"" + pluginPath.value()
+ + "\") conflicts with a core resource, skipping: " + clazz.getName()
+ + ". If this was an OSGI plugin, it has been migrated into core"
+ + " and the plugin should be uninstalled.");
+ return;
+ }
+
// Add the new class and reload
customClasses.put(clazz, true);
Logger.info(DotRestApplication.class,
diff --git a/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java b/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java
index 1e27f3f8436a..31d3b6a75ebf 100644
--- a/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java
+++ b/dotCMS/src/main/java/com/dotcms/system/event/local/business/LocalSystemEventSubscribersInitializer.java
@@ -1,5 +1,6 @@
package com.dotcms.system.event.local.business;
+import com.dotcms.cdn.pushpublish.receiver.event.PushPublishOnReceiverEndSubscriber;
import com.dotcms.ai.listener.AIAppListener;
import com.dotcms.analytics.listener.AnalyticsAppListener;
import com.dotcms.config.DotInitializer;
@@ -72,6 +73,8 @@ public void notify(final ChangeLoggerLevelEvent event) {
APILocator.getLocalSystemEventsAPI().subscribe(AppSecretSavedEvent.class, AnalyticsAppListener.Instance.get());
APILocator.getLocalSystemEventsAPI().subscribe(AppSecretSavedEvent.class, AIAppListener.Instance.get());
+ APILocator.getLocalSystemEventsAPI().subscribe(new PushPublishOnReceiverEndSubscriber());
+
this.initDotVelocityMacrosVtlFiles();
}
diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java
index 700a3cdbf4ba..e88b41731e8c 100644
--- a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java
+++ b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java
@@ -1,6 +1,7 @@
package com.dotmarketing.filters;
import com.dotcms.analytics.track.AnalyticsTrackWebInterceptor;
+import com.dotcms.cdn.CDNInterceptor;
import com.dotcms.business.SystemTableUpdatedKeyEvent;
import com.dotcms.ema.EMAWebInterceptor;
import com.dotcms.filters.interceptor.AbstractWebInterceptorSupportFilter;
@@ -56,6 +57,7 @@ private void addInterceptors(final FilterConfig config) {
delegate.add(new EventLogWebInterceptor());
delegate.add(new CurrentVariantWebInterceptor());
delegate.add(analyticsTrackWebInterceptor);
+ delegate.add(new CDNInterceptor());
APILocator.getLocalSystemEventsAPI().subscribe(SystemTableUpdatedKeyEvent.class, analyticsTrackWebInterceptor);
} // addInterceptors.
diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java
index bbd8876e840b..1797d359f42a 100644
--- a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java
@@ -22,6 +22,7 @@
import com.dotcms.exception.ExceptionUtil;
import com.dotcms.notifications.bean.NotificationLevel;
import com.dotcms.notifications.bean.NotificationType;
+import com.dotcms.cdn.workflow.DotCDNInvalidateActionlet;
import com.dotcms.rekognition.actionlet.RekognitionActionlet;
import com.dotcms.rendering.js.JsScriptActionlet;
import com.google.common.annotations.VisibleForTesting;
@@ -295,7 +296,8 @@ public WorkflowAPIImpl() {
OpenAIAutoTagActionlet.class,
AnalyticsFireUserEventActionlet.class,
OpenAIVisionAutoTagActionlet.class,
- OpenAITranslationActionlet.class
+ OpenAITranslationActionlet.class,
+ DotCDNInvalidateActionlet.class
));
refreshWorkFlowActionletMap();
diff --git a/dotCMS/src/main/resources/apps/dotCDN.yml b/dotCMS/src/main/resources/apps/dotCDN.yml
new file mode 100644
index 000000000000..3e09a1b257b8
--- /dev/null
+++ b/dotCMS/src/main/resources/apps/dotCDN.yml
@@ -0,0 +1,30 @@
+---
+name: "dotCDN"
+description: "This app connects your dotCMS instance with dotCMS's CDN for faster asset delivery. If you are interested in using this, please contact your dotCMS sales representative."
+iconUrl: "https://static.dotcms.com/assets/icons/apps/dotCDN.png"
+allowExtraParameters: false
+params:
+
+ cdnDomain:
+ label: "CDN domain"
+ value: ""
+ hidden: false
+ type: "STRING"
+ hint: "The base domain for your CDN, e.g. https://cdn.clientname.com"
+ required: true
+
+ cdnApiKey:
+ label: "CDN API/Client Key"
+ value: ""
+ hidden: true
+ type: "STRING"
+ hint: "CDN API Key (dotCMS will provide)"
+ required: true
+
+ cdnZoneId:
+ label: "CDN Zone ID"
+ value: ""
+ hidden: false
+ type: "STRING"
+ hint: "The machine ID for your CDN (dotCMS will provide)"
+ required: true
diff --git a/dotCMS/src/main/webapp/WEB-INF/portlet.xml b/dotCMS/src/main/webapp/WEB-INF/portlet.xml
index 859040355de0..9083c2320470 100644
--- a/dotCMS/src/main/webapp/WEB-INF/portlet.xml
+++ b/dotCMS/src/main/webapp/WEB-INF/portlet.xml
@@ -379,6 +379,12 @@
com.dotcms.rest.JSPPortlet
+
+ dotCDN
+ dotCDN
+ com.dotcms.spring.portlet.PortletController
+
+
diff --git a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml
index 23f8793935fc..991904a8fc36 100644
--- a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml
+++ b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml
@@ -74,6 +74,11 @@
request
com.dotcms.rendering.velocity.viewtools.DotRenderTool
+
+ dotcdn
+ request
+ com.dotcms.cdn.viewtool.DotCDNTool
+
import
request
diff --git a/dotCMS/src/test/java/com/dotcms/cdn/api/DotCDNAPITest.java b/dotCMS/src/test/java/com/dotcms/cdn/api/DotCDNAPITest.java
new file mode 100644
index 000000000000..dd49df83d99b
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/cdn/api/DotCDNAPITest.java
@@ -0,0 +1,326 @@
+package com.dotcms.cdn.api;
+
+import com.dotcms.cdn.CDNConstants;
+import com.dotcms.http.CircuitBreakerUrl;
+import com.dotcms.http.CircuitBreakerUrlBuilder;
+import com.dotcms.rest.RestClientBuilder;
+import com.dotcms.rest.exception.BadRequestException;
+import com.dotcms.security.apps.AppSecrets;
+import com.dotcms.security.apps.AppsAPI;
+import com.dotcms.security.apps.Secret;
+import com.dotcms.security.apps.Type;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import org.apache.http.conn.ConnectTimeoutException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.MockitoAnnotations;
+
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.Invocation;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the DotCDNAPI class. These tests verify the behavior of the API methods
+ * related to fetching CDN stats and invalidating URLs using a mock setup.
+ */
+class DotCDNAPITest {
+
+ private AutoCloseable closeable;
+
+ @BeforeEach
+ void setUp() {
+ closeable = MockitoAnnotations.openMocks(this);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ closeable.close();
+ }
+
+ @Mock
+ private AppsAPI appsAPI;
+
+ @Mock
+ private AppSecrets appSecrets;
+
+ @Mock
+ private CircuitBreakerUrl circuitBreakerUrl;
+
+ @Mock
+ private Host host;
+
+ @Mock
+ private Client restClient;
+
+ @Mock
+ private WebTarget webTarget;
+
+ @Mock
+ private Invocation.Builder invocationBuilder;
+
+ @Mock
+ private Response restResponse;
+
+ private static final String TEST_API_KEY = "test-api-key";
+ private static final String TEST_CDN_DOMAIN = "demo.dotcms.com";
+ private static final String TEST_ZONE_ID = "1234567890";
+ private static final String TEST_HOST_IDENTIFIER = "1234567890";
+
+ @Test
+ void test_getStats_Should_Return_Stats_For_Valid_Date_Range()
+ throws DotDataException, DotSecurityException, IOException {
+
+ try (MockedStatic apiLocator = mockStatic(APILocator.class)) {
+
+ setupAppsAPIMock(apiLocator);
+ setupCDNSecrets();
+ when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER);
+
+ try (MockedConstruction ignoredUrlBuilder =
+ mockConstruction(CircuitBreakerUrlBuilder.class,
+ (urlBuilder, context) ->
+ setCircuitBreakerUrlBuilderMock(urlBuilder))) {
+
+ final String statsResponse = Files.readString(Paths.get(
+ "src/test/resources/test-stats-response.json"),
+ StandardCharsets.UTF_8);
+ when(circuitBreakerUrl.doString()).thenReturn(statsResponse);
+
+ final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host);
+ final DotCDNStats dotCDNStats = dotCDNAPI.getStats("2023-10-01", "2023-10-02");
+ assertNotNull(dotCDNStats);
+ }
+ }
+ }
+
+ @Test
+ void test_getStats_Should_Throw_BadRequestException_For_Invalid_Dates()
+ throws DotDataException, DotSecurityException, IOException {
+
+ try (MockedStatic apiLocator = mockStatic(APILocator.class)) {
+
+ setupAppsAPIMock(apiLocator);
+ setupCDNSecrets();
+ when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER);
+
+ try (MockedConstruction ignoredUrlBuilder =
+ mockConstruction(CircuitBreakerUrlBuilder.class,
+ (urlBuilder, context) ->
+ setCircuitBreakerUrlBuilderMock(urlBuilder))) {
+
+ when(circuitBreakerUrl.doString()).thenThrow(new BadRequestException("Invalid date range"));
+ final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host);
+
+ assertThrows(BadRequestException.class, () ->
+ dotCDNAPI.getStats("2023-10-01", "2023-10-02")
+ );
+ }
+ }
+ }
+
+ @Test
+ void test_getStats_Should_Throw_Bad_Request_For_Timeout()
+ throws DotDataException, DotSecurityException, IOException {
+
+ try (MockedStatic apiLocator = mockStatic(APILocator.class)) {
+
+ setupAppsAPIMock(apiLocator);
+ setupCDNSecrets();
+ when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER);
+
+ try (MockedConstruction ignoredUrlBuilder =
+ mockConstruction(CircuitBreakerUrlBuilder.class,
+ (urlBuilder, context) ->
+ setCircuitBreakerUrlBuilderMock(urlBuilder))) {
+
+ when(circuitBreakerUrl.doString()).thenThrow(new ConnectTimeoutException("Request timed out"));
+ final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host);
+
+ assertThrows(BadRequestException.class, () ->
+ dotCDNAPI.getStats("2023-10-01", "2023-10-02")
+ );
+ }
+ }
+ }
+
+ @Test
+ void test_invalidate_Should_Return_True_For_Valid_Urls()
+ throws DotDataException, DotSecurityException, IOException {
+ try (MockedStatic apiLocator = mockStatic(APILocator.class)) {
+
+ setupAppsAPIMock(apiLocator);
+ setupCDNSecrets();
+ when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER);
+
+ @SuppressWarnings("unchecked")
+ final CircuitBreakerUrl.Response mockResponse =
+ org.mockito.Mockito.mock(CircuitBreakerUrl.Response.class);
+ when(mockResponse.getStatusCode()).thenReturn(200);
+ when(mockResponse.getResponse()).thenReturn("");
+
+ try (MockedConstruction ignoredUrlBuilder =
+ mockConstruction(CircuitBreakerUrlBuilder.class,
+ (urlBuilder, context) ->
+ setCircuitBreakerUrlBuilderMock(urlBuilder))) {
+
+ when(circuitBreakerUrl.doResponse()).thenReturn(mockResponse);
+
+ final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host);
+ final boolean result = dotCDNAPI.invalidate(List.of(
+ "demo.dotcms.com/page1", "demo.dotcms.com/page2"));
+
+ assertTrue(result);
+ }
+ }
+ }
+
+ @Test
+ void test_invalidate_Should_Return_False_For_Bad_Request_Response()
+ throws DotDataException, DotSecurityException, IOException {
+ try (MockedStatic apiLocator = mockStatic(APILocator.class)) {
+
+ setupAppsAPIMock(apiLocator);
+ setupCDNSecrets();
+ when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER);
+
+ @SuppressWarnings("unchecked")
+ final CircuitBreakerUrl.Response mockResponse =
+ org.mockito.Mockito.mock(CircuitBreakerUrl.Response.class);
+ when(mockResponse.getStatusCode()).thenReturn(400);
+ when(mockResponse.getResponse()).thenReturn("Bad Request");
+
+ try (MockedConstruction ignoredUrlBuilder =
+ mockConstruction(CircuitBreakerUrlBuilder.class,
+ (urlBuilder, context) ->
+ setCircuitBreakerUrlBuilderMock(urlBuilder))) {
+
+ when(circuitBreakerUrl.doResponse()).thenReturn(mockResponse);
+
+ final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host);
+ final boolean result = dotCDNAPI.invalidate(List.of(
+ "demo.dotcms.com/invalid-url-1"
+ ));
+
+ assertFalse(result);
+ }
+ }
+ }
+
+ @Test
+ void test_invalidateAll_Should_Return_True_For_Success()
+ throws DotDataException, DotSecurityException {
+ try (MockedStatic apiLocator = mockStatic(APILocator.class)) {
+
+ setupAppsAPIMock(apiLocator);
+ setupCDNSecrets();
+ when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER);
+
+ try (MockedStatic restClientBuilder = mockStatic(RestClientBuilder.class)) {
+
+ setupRestClientMock(restClientBuilder);
+ when(restResponse.getStatus()).thenReturn(Response.Status.NO_CONTENT.getStatusCode());
+
+ final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host);
+ final boolean result = dotCDNAPI.invalidateAll();
+
+ assertTrue(result);
+ }
+ }
+ }
+
+ @Test
+ void test_invalidateAll_Should_Throw_Bad_Request_For_Invalid_Request()
+ throws DotDataException, DotSecurityException {
+ try (MockedStatic apiLocator = mockStatic(APILocator.class)) {
+
+ setupAppsAPIMock(apiLocator);
+ setupCDNSecrets();
+ when(host.getIdentifier()).thenReturn(TEST_HOST_IDENTIFIER);
+
+ try (MockedStatic restClientBuilder = mockStatic(RestClientBuilder.class)) {
+
+ setupRestClientMock(restClientBuilder);
+ when(restResponse.getStatus()).thenReturn(Response.Status.BAD_REQUEST.getStatusCode());
+
+ final DotCDNAPI dotCDNAPI = DotCDNAPI.api(host);
+
+ assertThrows(BadRequestException.class, dotCDNAPI::invalidateAll);
+ }
+ }
+ }
+
+ private void setCircuitBreakerUrlBuilderMock(CircuitBreakerUrlBuilder urlBuilder) {
+ when(urlBuilder.setHeaders(anyMap())).thenReturn(urlBuilder);
+ when(urlBuilder.setTimeout(anyLong())).thenReturn(urlBuilder);
+ when(urlBuilder.setUrl(anyString())).thenReturn(urlBuilder);
+ when(urlBuilder.setMethod(any())).thenReturn(urlBuilder);
+ when(urlBuilder.setThrowWhenError(anyBoolean())).thenReturn(urlBuilder);
+ when(urlBuilder.build()).thenReturn(circuitBreakerUrl);
+ }
+
+ private void setupAppsAPIMock(final MockedStatic apiLocator)
+ throws DotDataException, DotSecurityException {
+ apiLocator.when(APILocator::getAppsAPI).thenReturn(appsAPI);
+ final Optional appSecretsResult = Optional.of(appSecrets);
+ when(appsAPI.getSecrets(eq(CDNConstants.DOT_CDN_APP_KEY),
+ anyBoolean(), any(), any())).thenReturn(appSecretsResult);
+ }
+
+ private void setupCDNSecrets() {
+ final Secret apiKeySecret = createSecret(TEST_API_KEY);
+ final Secret cdnDomainSecret = createSecret(TEST_CDN_DOMAIN);
+ final Secret zoneIdSecret = createSecret(TEST_ZONE_ID);
+
+ when(appSecrets.getSecrets()).thenReturn(Map.of(
+ CDNConstants.DOT_CDN_API_KEY, apiKeySecret,
+ CDNConstants.DOT_CDN_DOMAIN, cdnDomainSecret,
+ CDNConstants.DOT_CDN_ZONEID, zoneIdSecret
+ ));
+ }
+
+ private Secret createSecret(String value) {
+ return Secret.builder()
+ .withValue(value)
+ .withHidden(false)
+ .withType(Type.STRING)
+ .build();
+ }
+
+ private void setupRestClientMock(MockedStatic restClientBuilder) {
+ restClientBuilder.when(RestClientBuilder::newClient).thenReturn(restClient);
+ when(restClient.target(anyString())).thenReturn(webTarget);
+ when(webTarget.request(any(MediaType.class))).thenReturn(invocationBuilder);
+ when(invocationBuilder.header(anyString(), anyString())).thenReturn(invocationBuilder);
+ when(invocationBuilder.post(any())).thenReturn(restResponse);
+ }
+}
diff --git a/dotCMS/src/test/resources/test-stats-response.json b/dotCMS/src/test/resources/test-stats-response.json
new file mode 100644
index 000000000000..a234534abfe3
--- /dev/null
+++ b/dotCMS/src/test/resources/test-stats-response.json
@@ -0,0 +1,63 @@
+{
+ "TotalBandwidthUsed": 4358868943127,
+ "TotalOriginTraffic": 201443350067,
+ "AverageOriginResponseTime": 107,
+ "OriginResponseTimeChart": {
+ "2025-04-21T00:00:00Z": 119.97915496496852,
+ "2025-04-22T00:00:00Z": 123.14402618107543
+ },
+ "TotalRequestsServed": 59506919,
+ "CacheHitRate": 92.00229640522979,
+ "BandwidthUsedChart": {
+ "2025-04-21T00:00:00Z": 323843276876,
+ "2025-04-22T00:00:00Z": 140979625395
+ },
+ "BandwidthCachedChart": {
+ "2025-04-21T00:00:00Z": 319176563428,
+ "2025-04-22T00:00:00Z": 139213074644
+ },
+ "CacheHitRateChart": {
+ "2025-04-21T00:00:00Z": 87.98853939047213,
+ "2025-04-22T00:00:00Z": 89.02157708669782
+ },
+ "RequestsServedChart": {
+ "2025-04-21T00:00:00Z": 4513198,
+ "2025-04-22T00:00:00Z": 1997582
+ },
+ "PullRequestsPulledChart": {
+ "2025-04-21T00:00:00Z": 542101,
+ "2025-04-22T00:00:00Z": 219303
+ },
+ "OriginShieldBandwidthUsedChart": {
+ "2025-04-21T00:00:00Z": 2655412083,
+ "2025-04-22T00:00:00Z": 1383082792
+ },
+ "OriginShieldInternalBandwidthUsedChart": {
+ "2025-04-21T00:00:00Z": 6216959163,
+ "2025-04-22T00:00:00Z": 3061534513
+ },
+ "OriginTrafficChart": {
+ "2025-04-21T00:00:00Z": 14447333558,
+ "2025-04-22T00:00:00Z": 7375473616
+ },
+ "UserBalanceHistoryChart": {
+ "2025-04-21T01:26:36": -657.07,
+ "2025-04-22T01:21:46": -688.27
+ },
+ "GeoTrafficDistribution": {
+ "NA: New York City, NY": 411752804365,
+ "NA: Miami, FL": 250155831412
+ },
+ "Error3xxChart": {
+ "2025-04-21T00:00:00Z": 0,
+ "2025-04-22T00:00:00Z": 0
+ },
+ "Error4xxChart": {
+ "2025-04-21T00:00:00Z": 0,
+ "2025-04-22T00:00:00Z": 0
+ },
+ "Error5xxChart": {
+ "2025-04-21T00:00:00Z": 0,
+ "2025-04-22T00:00:00Z": 0
+ }
+}