@@ -146,7 +145,7 @@ export function CommentHeader({
) : (
-
+
Pinned
diff --git a/apps/shade/src/components/ui/badge.stories.tsx b/apps/shade/src/components/ui/badge.stories.tsx
index 67d3e677e6b..c689768224b 100644
--- a/apps/shade/src/components/ui/badge.stories.tsx
+++ b/apps/shade/src/components/ui/badge.stories.tsx
@@ -15,7 +15,7 @@ const meta = {
argTypes: {
variant: {
control: {type: 'select'},
- options: ['default', 'secondary', 'destructive', 'success', 'outline']
+ options: ['default', 'secondary', 'destructive', 'success', 'warning', 'outline']
}
}
} satisfies Meta;
@@ -50,6 +50,13 @@ export const Success: Story = {
}
};
+export const Warning: Story = {
+ args: {
+ variant: 'warning',
+ children: 'Warning'
+ }
+};
+
export const Outline: Story = {
args: {
variant: 'outline',
@@ -64,6 +71,7 @@ export const AllVariants: Story = {
Secondary
Error
Success
+ Warning
Outline
)
diff --git a/apps/shade/src/components/ui/badge.tsx b/apps/shade/src/components/ui/badge.tsx
index 90fd6491310..48cc9ab3159 100644
--- a/apps/shade/src/components/ui/badge.tsx
+++ b/apps/shade/src/components/ui/badge.tsx
@@ -16,6 +16,8 @@ const badgeVariants = cva(
'border-transparent bg-destructive/20 text-destructive',
success:
'border-transparent bg-green/20 text-green',
+ warning:
+ 'border-transparent bg-state-warning/20 text-yellow-600',
outline: 'text-foreground'
}
},
diff --git a/apps/shade/test/unit/components/ui/badge.test.tsx b/apps/shade/test/unit/components/ui/badge.test.tsx
index b9ec0ffacb1..60e558873a7 100644
--- a/apps/shade/test/unit/components/ui/badge.test.tsx
+++ b/apps/shade/test/unit/components/ui/badge.test.tsx
@@ -34,6 +34,13 @@ describe('Badge Component', () => {
assert.ok(badge.className.includes('bg-green'), 'Should have success variant class');
});
+ it('applies warning variant correctly', () => {
+ render(
Warning Badge);
+ const badge = screen.getByText('Warning Badge');
+
+ assert.ok(badge.className.includes('bg-state-warning'), 'Should have warning variant class');
+ });
+
it('applies outline variant correctly', () => {
render(
Outline Badge);
const badge = screen.getByText('Outline Badge');
@@ -54,4 +61,4 @@ describe('Badge Component', () => {
assert.equal(badge.textContent, 'Test Badge', 'Should render the text content');
});
-});
\ No newline at end of file
+});
diff --git a/ghost/admin/package.json b/ghost/admin/package.json
index 7e258dc2770..b5f105c13ec 100644
--- a/ghost/admin/package.json
+++ b/ghost/admin/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost-admin",
- "version": "6.40.0-rc.0",
+ "version": "6.40.1-rc.0",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
diff --git a/ghost/core/core/server/services/custom-redirects/file-store.ts b/ghost/core/core/server/adapters/redirects/FileStore.ts
similarity index 90%
rename from ghost/core/core/server/services/custom-redirects/file-store.ts
rename to ghost/core/core/server/adapters/redirects/FileStore.ts
index 4250fa71b37..98b8b55fa6e 100644
--- a/ghost/core/core/server/services/custom-redirects/file-store.ts
+++ b/ghost/core/core/server/adapters/redirects/FileStore.ts
@@ -1,9 +1,10 @@
import fs from 'fs-extra';
import path from 'path';
-import {parseJson, parseYaml} from './redirect-config-parser';
-import {getBackupRedirectsFilePath} from './utils';
-import type {RedirectConfig, RedirectsStore} from './types';
+import RedirectsStoreBase from './RedirectsStoreBase';
+import {parseJson, parseYaml} from '../../services/custom-redirects/redirect-config-parser';
+import {getBackupRedirectsFilePath} from '../../services/custom-redirects/utils';
+import type {RedirectConfig, RedirectsStore} from '../../services/custom-redirects/types';
const YAML_FILENAME = 'redirects.yaml';
const JSON_FILENAME = 'redirects.json';
@@ -19,11 +20,12 @@ interface FileStoreOptions {
* `.json`. The previous canonical file becomes a timestamped backup on
* every successive `replaceAll`.
*/
-export class FileStore implements RedirectsStore {
+export default class FileStore extends RedirectsStoreBase implements RedirectsStore {
private readonly basePath: string;
private readonly getBackupFilePath: (filePath: string) => string;
constructor({basePath, getBackupFilePath = getBackupRedirectsFilePath}: FileStoreOptions) {
+ super();
this.basePath = basePath;
this.getBackupFilePath = getBackupFilePath;
}
diff --git a/ghost/core/core/server/adapters/redirects/RedirectsStoreBase.d.ts b/ghost/core/core/server/adapters/redirects/RedirectsStoreBase.d.ts
new file mode 100644
index 00000000000..76d83c055bf
--- /dev/null
+++ b/ghost/core/core/server/adapters/redirects/RedirectsStoreBase.d.ts
@@ -0,0 +1,9 @@
+/* eslint-disable ghost/filenames/match-regex --
+ * PascalCase mirrors the runtime `RedirectsStoreBase.js` so the TS
+ * adapter can default-import via the matching declaration file.
+ */
+declare class RedirectsStoreBase {
+ readonly requiredFns: ReadonlyArray
;
+}
+
+export default RedirectsStoreBase;
diff --git a/ghost/core/core/server/adapters/redirects/RedirectsStoreBase.js b/ghost/core/core/server/adapters/redirects/RedirectsStoreBase.js
new file mode 100644
index 00000000000..1d215bdc7a3
--- /dev/null
+++ b/ghost/core/core/server/adapters/redirects/RedirectsStoreBase.js
@@ -0,0 +1,8 @@
+module.exports = class RedirectsStoreBase {
+ constructor() {
+ Object.defineProperty(this, 'requiredFns', {
+ value: ['getAll', 'replaceAll'],
+ writable: false
+ });
+ }
+};
diff --git a/ghost/core/core/server/adapters/redirects/S3RedirectsStore.ts b/ghost/core/core/server/adapters/redirects/S3RedirectsStore.ts
new file mode 100644
index 00000000000..37e53f3c337
--- /dev/null
+++ b/ghost/core/core/server/adapters/redirects/S3RedirectsStore.ts
@@ -0,0 +1,163 @@
+import {
+ CopyObjectCommand,
+ GetObjectCommand,
+ HeadObjectCommand,
+ NoSuchKey,
+ NotFound,
+ PutObjectCommand,
+ S3Client,
+ S3ClientConfig
+} from '@aws-sdk/client-s3';
+import tpl from '@tryghost/tpl';
+import * as errors from '@tryghost/errors';
+
+import RedirectsStoreBase from './RedirectsStoreBase';
+import {parseJson} from '../../services/custom-redirects/redirect-config-parser';
+import {getBackupRedirectsFilePath} from '../../services/custom-redirects/utils';
+import type {RedirectConfig, RedirectsStore} from '../../services/custom-redirects/types';
+
+const DEFAULT_FILENAME = 'redirects.json';
+
+const messages = {
+ missingBucket: 'S3RedirectsStore requires a bucket name',
+ missingStaticFileURLPrefix: 'S3RedirectsStore requires a staticFileURLPrefix',
+ partialCredentials: 'S3RedirectsStore requires both accessKeyId and secretAccessKey when either is provided',
+ missingResponseBody: 'S3 GetObject returned no body'
+};
+
+const stripLeadingAndTrailingSlashes = (value = '') => value.replace(/^\/+|\/+$/g, '');
+
+export interface S3RedirectsStoreOptions {
+ bucket: string;
+ staticFileURLPrefix: string;
+ region?: string;
+ endpoint?: string;
+ forcePathStyle?: boolean;
+ accessKeyId?: string;
+ secretAccessKey?: string;
+ sessionToken?: string;
+ tenantPrefix?: string;
+}
+
+/**
+ * Implements RedirectsStore against an S3-compatible bucket. Reads and
+ * writes a single JSON object at the configured key, keeping a
+ * timestamped server-side copy of the previous contents on each
+ * overwrite.
+ */
+export default class S3RedirectsStore extends RedirectsStoreBase implements RedirectsStore {
+ private readonly client: S3Client;
+ private readonly bucket: string;
+ private readonly staticFileURLPrefix: string;
+ private readonly tenantPrefix: string;
+
+ constructor(options: S3RedirectsStoreOptions) {
+ super();
+ if (!options.bucket) {
+ throw new errors.IncorrectUsageError({
+ message: tpl(messages.missingBucket)
+ });
+ }
+
+ const staticFileURLPrefix = stripLeadingAndTrailingSlashes(options.staticFileURLPrefix);
+ if (!staticFileURLPrefix) {
+ throw new errors.IncorrectUsageError({
+ message: tpl(messages.missingStaticFileURLPrefix)
+ });
+ }
+
+ const hasAccessKey = Boolean(options.accessKeyId);
+ const hasSecretKey = Boolean(options.secretAccessKey);
+ const hasSessionToken = Boolean(options.sessionToken);
+ const hasCredentialPair = hasAccessKey && hasSecretKey;
+ if ((hasAccessKey || hasSecretKey || hasSessionToken) && !hasCredentialPair) {
+ throw new errors.IncorrectUsageError({
+ message: tpl(messages.partialCredentials)
+ });
+ }
+
+ this.bucket = options.bucket;
+ this.staticFileURLPrefix = staticFileURLPrefix;
+ this.tenantPrefix = stripLeadingAndTrailingSlashes(options.tenantPrefix);
+
+ const clientConfig: S3ClientConfig = {
+ region: options.region,
+ endpoint: options.endpoint,
+ forcePathStyle: options.forcePathStyle
+ };
+ if (hasCredentialPair) {
+ clientConfig.credentials = {
+ accessKeyId: options.accessKeyId!,
+ secretAccessKey: options.secretAccessKey!,
+ sessionToken: options.sessionToken
+ };
+ }
+ this.client = new S3Client(clientConfig);
+ }
+
+ async getAll(): Promise {
+ let body: string;
+ try {
+ const response = await this.client.send(new GetObjectCommand({
+ Bucket: this.bucket,
+ Key: this.buildKey()
+ }));
+ if (!response.Body) {
+ throw new errors.InternalServerError({
+ message: tpl(messages.missingResponseBody)
+ });
+ }
+ body = await response.Body.transformToString('utf-8');
+ } catch (err) {
+ if (this._isNotFound(err)) {
+ return [];
+ }
+ throw err;
+ }
+
+ return parseJson(body);
+ }
+
+ async replaceAll(redirects: RedirectConfig[]): Promise {
+ const key = this.buildKey();
+
+ if (await this._canonicalExists()) {
+ await this.client.send(new CopyObjectCommand({
+ Bucket: this.bucket,
+ Key: getBackupRedirectsFilePath(key),
+ CopySource: `${this.bucket}/${key}`
+ }));
+ }
+
+ await this.client.send(new PutObjectCommand({
+ Bucket: this.bucket,
+ Key: key,
+ Body: JSON.stringify(redirects),
+ ContentType: 'application/json'
+ }));
+ }
+
+ private buildKey(): string {
+ const parts = [this.tenantPrefix, this.staticFileURLPrefix, DEFAULT_FILENAME].filter(Boolean);
+ return parts.join('/');
+ }
+
+ private async _canonicalExists(): Promise {
+ try {
+ await this.client.send(new HeadObjectCommand({
+ Bucket: this.bucket,
+ Key: this.buildKey()
+ }));
+ return true;
+ } catch (err) {
+ if (this._isNotFound(err)) {
+ return false;
+ }
+ throw err;
+ }
+ }
+
+ private _isNotFound(err: unknown): boolean {
+ return err instanceof NotFound || err instanceof NoSuchKey;
+ }
+}
diff --git a/ghost/core/core/server/services/adapter-manager/config.js b/ghost/core/core/server/services/adapter-manager/config.js
index d65a78e93f8..ac0fd67cb10 100644
--- a/ghost/core/core/server/services/adapter-manager/config.js
+++ b/ghost/core/core/server/services/adapter-manager/config.js
@@ -25,5 +25,11 @@ module.exports = function getAdapterServiceConfig(config) {
};
}
+ // FileStore needs Ghost's resolved content path, which isn't
+ // representable as a static value in defaults.json.
+ if (adapterServiceConfig.redirects?.FileStore) {
+ adapterServiceConfig.redirects.FileStore.basePath ||= config.getContentPath('data');
+ }
+
return adapterServiceConfig;
};
diff --git a/ghost/core/core/server/services/adapter-manager/index.js b/ghost/core/core/server/services/adapter-manager/index.js
index f6c3a39f416..736643ce74a 100644
--- a/ghost/core/core/server/services/adapter-manager/index.js
+++ b/ghost/core/core/server/services/adapter-manager/index.js
@@ -16,6 +16,7 @@ adapterManager.registerAdapter('storage', require('ghost-storage-base'));
adapterManager.registerAdapter('scheduling', require('../../adapters/scheduling/scheduling-base'));
adapterManager.registerAdapter('sso', require('../../adapters/sso/SSOBase'));
adapterManager.registerAdapter('cache', require('@tryghost/adapter-base-cache'));
+adapterManager.registerAdapter('redirects', require('../../adapters/redirects/RedirectsStoreBase'));
module.exports = {
/**
diff --git a/ghost/core/core/server/services/custom-redirects/gcs-store.ts b/ghost/core/core/server/services/custom-redirects/gcs-store.ts
deleted file mode 100644
index 46b6db7d4e3..00000000000
--- a/ghost/core/core/server/services/custom-redirects/gcs-store.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import {
- CopyObjectCommand,
- GetObjectCommand,
- HeadObjectCommand,
- NoSuchKey,
- NotFound,
- PutObjectCommand,
- S3Client
-} from '@aws-sdk/client-s3';
-import tpl from '@tryghost/tpl';
-import * as errors from '@tryghost/errors';
-
-import {parseJson} from './redirect-config-parser';
-import {getBackupRedirectsFilePath} from './utils';
-import type {RedirectConfig, RedirectsStore} from './types';
-
-const DEFAULT_KEY = 'redirects.json';
-
-const messages = {
- missingBucket: 'GCSStore requires a bucket name',
- missingClient: 'GCSStore requires an S3 client',
- missingResponseBody: 'S3 GetObject returned no body'
-};
-
-export interface GCSStoreOptions {
- bucket: string;
- s3Client: S3Client;
- getBackupKey?: (key: string) => string;
-}
-
-/**
- * Implements RedirectsStore against an S3-compatible bucket. Reads and
- * writes a single JSON object at the configured key, keeping a
- * timestamped server-side copy of the previous contents on each
- * overwrite.
- */
-export class GCSStore implements RedirectsStore {
- private readonly client: S3Client;
- private readonly bucket: string;
- private readonly getBackupKey: (key: string) => string;
-
- constructor(options: GCSStoreOptions) {
- if (!options.bucket) {
- throw new errors.IncorrectUsageError({
- message: tpl(messages.missingBucket)
- });
- }
- if (!options.s3Client) {
- throw new errors.IncorrectUsageError({
- message: tpl(messages.missingClient)
- });
- }
-
- this.bucket = options.bucket;
- this.client = options.s3Client;
- this.getBackupKey = options.getBackupKey || getBackupRedirectsFilePath;
- }
-
- async getAll(): Promise {
- let body: string;
- try {
- const response = await this.client.send(new GetObjectCommand({
- Bucket: this.bucket,
- Key: DEFAULT_KEY
- }));
- if (!response.Body) {
- throw new errors.InternalServerError({
- message: tpl(messages.missingResponseBody)
- });
- }
- body = await response.Body.transformToString('utf-8');
- } catch (err) {
- if (this._isNotFound(err)) {
- return [];
- }
- throw err;
- }
-
- return parseJson(body);
- }
-
- async replaceAll(redirects: RedirectConfig[]): Promise {
- if (await this._canonicalExists()) {
- await this.client.send(new CopyObjectCommand({
- Bucket: this.bucket,
- Key: this.getBackupKey(DEFAULT_KEY),
- CopySource: `${this.bucket}/${DEFAULT_KEY}`
- }));
- }
-
- await this.client.send(new PutObjectCommand({
- Bucket: this.bucket,
- Key: DEFAULT_KEY,
- Body: JSON.stringify(redirects),
- ContentType: 'application/json'
- }));
- }
-
- private async _canonicalExists(): Promise {
- try {
- await this.client.send(new HeadObjectCommand({
- Bucket: this.bucket,
- Key: DEFAULT_KEY
- }));
- return true;
- } catch (err) {
- if (this._isNotFound(err)) {
- return false;
- }
- throw err;
- }
- }
-
- private _isNotFound(err: unknown): boolean {
- return err instanceof NotFound || err instanceof NoSuchKey;
- }
-}
diff --git a/ghost/core/core/server/services/custom-redirects/index.js b/ghost/core/core/server/services/custom-redirects/index.js
index bb0dc9efc9b..f5d5f61a462 100644
--- a/ghost/core/core/server/services/custom-redirects/index.js
+++ b/ghost/core/core/server/services/custom-redirects/index.js
@@ -1,8 +1,8 @@
const config = require('../../../shared/config');
const urlUtils = require('../../../shared/url-utils');
+const adapterManager = require('../adapter-manager');
const DynamicRedirectManager = require('../lib/dynamic-redirect-manager');
-const {FileStore} = require('./file-store');
const {RedirectsService} = require('./redirects-service');
const validation = require('./validation');
@@ -18,9 +18,7 @@ module.exports = {
init() {
redirectManager = makeRedirectManager();
- const store = new FileStore({
- basePath: config.getContentPath('data')
- });
+ const store = adapterManager.getAdapter('redirects');
redirectsService = new RedirectsService({
store,
diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json
index a54ce3bd6d0..f13892150e3 100644
--- a/ghost/core/core/shared/config/defaults.json
+++ b/ghost/core/core/shared/config/defaults.json
@@ -34,6 +34,11 @@
"settings": {},
"imageSizes": {},
"gscan": {}
+ },
+ "redirects": {
+ "active": "FileStore",
+ "FileStore": {},
+ "S3RedirectsStore": {}
}
},
"storage": {
diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js
index 5e555e5972a..c010b80f22b 100644
--- a/ghost/core/core/shared/labs.js
+++ b/ghost/core/core/shared/labs.js
@@ -24,7 +24,8 @@ const GA_FEATURES = [
'customFonts',
'explore',
'commentModeration',
- 'featurebaseFeedback'
+ 'featurebaseFeedback',
+ 'giftSubscriptions'
];
// These features are considered publicly available and can be enabled/disabled by users
@@ -50,7 +51,6 @@ const PRIVATE_FEATURES = [
'indexnow',
'pictureImageFormats',
'smarterCounts',
- 'giftSubscriptions',
'commentsThreads',
'commentsPinning'
];
diff --git a/ghost/core/package.json b/ghost/core/package.json
index a7e87277f0e..147ca260de9 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost",
- "version": "6.40.0-rc.0",
+ "version": "6.40.1-rc.0",
"description": "The professional publishing platform",
"author": "Ghost Foundation",
"homepage": "https://ghost.org",
diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap
index 743c7632d86..c23382a0281 100644
--- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap
+++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap
@@ -1803,7 +1803,7 @@ exports[`Settings API Edit can edit Stripe settings when Stripe Connect limit is
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
- "content-length": "5379",
+ "content-length": "5406",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
diff --git a/ghost/core/test/integration/adapters/redirects/s3-redirects-store.test.ts b/ghost/core/test/integration/adapters/redirects/s3-redirects-store.test.ts
new file mode 100644
index 00000000000..5bdcb61f558
--- /dev/null
+++ b/ghost/core/test/integration/adapters/redirects/s3-redirects-store.test.ts
@@ -0,0 +1,176 @@
+/* eslint-disable ghost/mocha/no-setup-in-describe -- runStoreContract is the parameterised-test seam; calling it inside describe is the intended use. */
+import assert from 'node:assert/strict';
+import {ListObjectsV2Command, S3Client} from '@aws-sdk/client-s3';
+
+import S3RedirectsStore from '../../../../core/server/adapters/redirects/S3RedirectsStore';
+import {
+ createTestS3Client,
+ createTestBucket,
+ emptyTestBucket,
+ deleteTestBucket,
+ getMinioConfig,
+ getObject,
+ putObject
+} from '../../../utils/minio';
+import {runStoreContract} from '../../../unit/server/services/custom-redirects/helpers/store-contract';
+
+const STATIC_PREFIX = 'content/data';
+const CANONICAL_FILENAME = 'redirects.json';
+
+const canonicalKey = (tenantPrefix = '') => [tenantPrefix, STATIC_PREFIX, CANONICAL_FILENAME].filter(Boolean).join('/');
+
+const listObjectKeys = async (s3Client: S3Client, bucketName: string): Promise => {
+ const response = await s3Client.send(new ListObjectsV2Command({Bucket: bucketName}));
+ return (response.Contents ?? []).map(o => o.Key ?? '').filter(Boolean);
+};
+
+const sleep = (ms: number): Promise => new Promise((resolve) => {
+ setTimeout(resolve, ms);
+});
+
+const backupKeyPattern = (tenantPrefix = '') => new RegExp(
+ `^${tenantPrefix ? `${tenantPrefix}/` : ''}${STATIC_PREFIX}/redirects-\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2}\\.json$`
+);
+
+describe('Integration: S3RedirectsStore', function () {
+ let adminClient: S3Client;
+ let bucket: string;
+ const minioConfig = getMinioConfig();
+
+ before(async function () {
+ adminClient = createTestS3Client();
+ bucket = await createTestBucket(adminClient);
+ });
+
+ afterEach(async function () {
+ await emptyTestBucket(adminClient, bucket);
+ });
+
+ after(async function () {
+ await deleteTestBucket(adminClient, bucket);
+ });
+
+ runStoreContract({
+ createStore: () => new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX})
+ });
+
+ describe('getAll: error handling', function () {
+ it('throws when redirects.json is corrupt', async function () {
+ await putObject(adminClient, bucket, canonicalKey(), '{not valid');
+
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX});
+
+ await assert.rejects(
+ () => store.getAll(),
+ {errorType: 'BadRequestError'}
+ );
+ });
+ });
+
+ describe('replaceAll: timestamped backups', function () {
+ it('writes the canonical key without a backup when the bucket is empty', async function () {
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX});
+
+ await store.replaceAll([{from: '/a', to: '/b', permanent: true}]);
+
+ assert.deepEqual(await listObjectKeys(adminClient, bucket), [canonicalKey()]);
+ });
+
+ it('backs up the prior contents before overwriting', async function () {
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX});
+ const initial = [{from: '/old', to: '/old-target', permanent: true}];
+
+ await store.replaceAll(initial);
+ await store.replaceAll([{from: '/new', to: '/new-target', permanent: false}]);
+
+ const keys = await listObjectKeys(adminClient, bucket);
+ const backupKey = keys.find(k => backupKeyPattern().test(k));
+ assert.ok(backupKey, `expected a timestamped backup key, got: ${keys.join(', ')}`);
+
+ const backupBody = await getObject(adminClient, bucket, backupKey!);
+ assert.equal(backupBody?.toString('utf-8'), JSON.stringify(initial));
+ });
+
+ it('creates a new backup on every overwrite', async function () {
+ // The backup key generator uses a per-second timestamp, so
+ // real waits between writes are needed to guarantee distinct
+ // backup keys.
+ this.timeout(15000);
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX});
+
+ await store.replaceAll([{from: '/a', to: '/a', permanent: true}]);
+ await sleep(1100);
+ await store.replaceAll([{from: '/b', to: '/b', permanent: true}]);
+ await sleep(1100);
+ await store.replaceAll([{from: '/c', to: '/c', permanent: true}]);
+
+ const keys = await listObjectKeys(adminClient, bucket);
+ const backupKeys = keys.filter(k => backupKeyPattern().test(k));
+ assert.equal(backupKeys.length, 2, `expected 2 timestamped backups, got: ${keys.join(', ')}`);
+ assert.ok(keys.includes(canonicalKey()), `expected canonical ${canonicalKey()}, got: ${keys.join(', ')}`);
+ });
+ });
+
+ describe('tenantPrefix scoping', function () {
+ it('writes the canonical key under the tenant prefix', async function () {
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX, tenantPrefix: 'tenant-abc'});
+
+ await store.replaceAll([{from: '/a', to: '/b', permanent: true}]);
+
+ assert.deepEqual(
+ await listObjectKeys(adminClient, bucket),
+ [canonicalKey('tenant-abc')]
+ );
+ });
+
+ it('reads back redirects from the prefixed key', async function () {
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX, tenantPrefix: 'tenant-abc'});
+ const redirects = [{from: '/old', to: '/new', permanent: true}];
+
+ await store.replaceAll(redirects);
+
+ assert.deepEqual(await store.getAll(), redirects);
+ });
+
+ it('writes backups under the tenant prefix on overwrite', async function () {
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX, tenantPrefix: 'tenant-abc'});
+ const initial = [{from: '/old', to: '/old-target', permanent: true}];
+
+ await store.replaceAll(initial);
+ await store.replaceAll([{from: '/new', to: '/new-target', permanent: false}]);
+
+ const keys = await listObjectKeys(adminClient, bucket);
+ const backupKey = keys.find(k => backupKeyPattern('tenant-abc').test(k));
+ assert.ok(backupKey, `expected a tenant-scoped backup key, got: ${keys.join(', ')}`);
+
+ const backupBody = await getObject(adminClient, bucket, backupKey!);
+ assert.equal(backupBody?.toString('utf-8'), JSON.stringify(initial));
+ });
+
+ it('isolates tenants sharing the same bucket', async function () {
+ const storeA = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX, tenantPrefix: 'tenant-a'});
+ const storeB = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX, tenantPrefix: 'tenant-b'});
+
+ await storeA.replaceAll([{from: '/a', to: '/a-target', permanent: true}]);
+ await storeB.replaceAll([{from: '/b', to: '/b-target', permanent: false}]);
+
+ assert.deepEqual(await storeA.getAll(), [{from: '/a', to: '/a-target', permanent: true}]);
+ assert.deepEqual(await storeB.getAll(), [{from: '/b', to: '/b-target', permanent: false}]);
+ assert.deepEqual(
+ (await listObjectKeys(adminClient, bucket)).sort(),
+ [canonicalKey('tenant-a'), canonicalKey('tenant-b')]
+ );
+ });
+
+ it('strips leading and trailing slashes from the tenant prefix', async function () {
+ const store = new S3RedirectsStore({...minioConfig, bucket, staticFileURLPrefix: STATIC_PREFIX, tenantPrefix: '/tenant-abc/'});
+
+ await store.replaceAll([{from: '/a', to: '/b', permanent: true}]);
+
+ assert.deepEqual(
+ await listObjectKeys(adminClient, bucket),
+ [canonicalKey('tenant-abc')]
+ );
+ });
+ });
+});
diff --git a/ghost/core/test/integration/services/custom-redirects/gcs-store.test.ts b/ghost/core/test/integration/services/custom-redirects/gcs-store.test.ts
deleted file mode 100644
index 15696655abf..00000000000
--- a/ghost/core/test/integration/services/custom-redirects/gcs-store.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/* eslint-disable ghost/mocha/no-setup-in-describe -- runStoreContract is the parameterised-test seam; calling it inside describe is the intended use. */
-import assert from 'node:assert/strict';
-import {ListObjectsV2Command, S3Client} from '@aws-sdk/client-s3';
-
-import {GCSStore} from '../../../../core/server/services/custom-redirects/gcs-store';
-import {
- createTestS3Client,
- createTestBucket,
- emptyTestBucket,
- deleteTestBucket,
- getObject,
- putObject
-} from '../../../utils/minio';
-import {runStoreContract} from '../../../unit/server/services/custom-redirects/helpers/store-contract';
-
-const listObjectKeys = async (s3Client: S3Client, bucketName: string): Promise => {
- const response = await s3Client.send(new ListObjectsV2Command({Bucket: bucketName}));
- return (response.Contents ?? []).map(o => o.Key ?? '').filter(Boolean);
-};
-
-describe('Integration: GCSStore', function () {
- let client: S3Client;
- let bucket: string;
-
- before(async function () {
- client = createTestS3Client();
- bucket = await createTestBucket(client);
- });
-
- afterEach(async function () {
- await emptyTestBucket(client, bucket);
- });
-
- after(async function () {
- await deleteTestBucket(client, bucket);
- });
-
- runStoreContract({
- createStore: () => new GCSStore({s3Client: client, bucket})
- });
-
- describe('getAll: error handling', function () {
- it('throws when redirects.json is corrupt', async function () {
- await putObject(client, bucket, 'redirects.json', '{not valid');
-
- const store = new GCSStore({s3Client: client, bucket});
-
- await assert.rejects(
- () => store.getAll(),
- {errorType: 'BadRequestError'}
- );
- });
- });
-
- describe('replaceAll: timestamped backups', function () {
- it('writes the canonical key without a backup when the bucket is empty', async function () {
- const store = new GCSStore({s3Client: client, bucket});
-
- await store.replaceAll([{from: '/a', to: '/b', permanent: true}]);
-
- assert.deepEqual(await listObjectKeys(client, bucket), ['redirects.json']);
- });
-
- it('backs up the prior contents before overwriting', async function () {
- // Inject a stable backup key — the default per-second
- // timestamp would otherwise make this test depend on
- // wall-clock granularity.
- const backupKey = 'redirects-backup.json';
- const store = new GCSStore({
- s3Client: client,
- bucket,
- getBackupKey: () => backupKey
- });
- const initial = [{from: '/old', to: '/old-target', permanent: true}];
-
- await store.replaceAll(initial);
- await store.replaceAll([{from: '/new', to: '/new-target', permanent: false}]);
-
- const backupBody = await getObject(client, bucket, backupKey);
- assert.equal(backupBody?.toString('utf-8'), JSON.stringify(initial));
- });
-
- it('creates a new backup on every overwrite', async function () {
- // Counter-based generator gives each overwrite a distinct
- // backup key, so the test can assert accumulation without
- // depending on wall-clock granularity.
- let backupCounter = 0;
- const store = new GCSStore({
- s3Client: client,
- bucket,
- getBackupKey: () => {
- backupCounter += 1;
- return `redirects-backup-${backupCounter}.json`;
- }
- });
-
- await store.replaceAll([{from: '/a', to: '/a', permanent: true}]);
- await store.replaceAll([{from: '/b', to: '/b', permanent: true}]);
- await store.replaceAll([{from: '/c', to: '/c', permanent: true}]);
-
- const keys = (await listObjectKeys(client, bucket)).sort();
- assert.deepEqual(keys, [
- 'redirects-backup-1.json',
- 'redirects-backup-2.json',
- 'redirects.json'
- ]);
- });
- });
-});
diff --git a/ghost/core/test/unit/server/services/custom-redirects/file-store.test.ts b/ghost/core/test/unit/server/adapters/redirects/file-store.test.ts
similarity index 98%
rename from ghost/core/test/unit/server/services/custom-redirects/file-store.test.ts
rename to ghost/core/test/unit/server/adapters/redirects/file-store.test.ts
index 9631126f52b..dd6b4f7af5c 100644
--- a/ghost/core/test/unit/server/services/custom-redirects/file-store.test.ts
+++ b/ghost/core/test/unit/server/adapters/redirects/file-store.test.ts
@@ -5,8 +5,8 @@ import path from 'path';
import os from 'os';
import crypto from 'crypto';
-import {FileStore} from '../../../../../core/server/services/custom-redirects/file-store';
-import {runStoreContract} from './helpers/store-contract';
+import FileStore from '../../../../../core/server/adapters/redirects/FileStore';
+import {runStoreContract} from '../../services/custom-redirects/helpers/store-contract';
const writeJson = (filePath: string, data: unknown): Promise => fs.writeFile(filePath, JSON.stringify(data), 'utf-8');
diff --git a/ghost/core/test/unit/server/adapters/redirects/s3-redirects-store.test.ts b/ghost/core/test/unit/server/adapters/redirects/s3-redirects-store.test.ts
new file mode 100644
index 00000000000..f62bcea5429
--- /dev/null
+++ b/ghost/core/test/unit/server/adapters/redirects/s3-redirects-store.test.ts
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict';
+
+import S3RedirectsStore from '../../../../../core/server/adapters/redirects/S3RedirectsStore';
+
+describe('UNIT: S3RedirectsStore', function () {
+ describe('constructor validation', function () {
+ it('throws when no bucket is provided', function () {
+ assert.throws(
+ () => new S3RedirectsStore({} as never),
+ {errorType: 'IncorrectUsageError', message: /bucket/}
+ );
+ });
+
+ it('throws when no staticFileURLPrefix is provided', function () {
+ assert.throws(
+ () => new S3RedirectsStore({bucket: 'x'} as never),
+ {errorType: 'IncorrectUsageError', message: /staticFileURLPrefix/}
+ );
+ });
+
+ it('throws when only accessKeyId is provided', function () {
+ assert.throws(
+ () => new S3RedirectsStore({bucket: 'x', staticFileURLPrefix: 'content/data', accessKeyId: 'AKIA'}),
+ {errorType: 'IncorrectUsageError', message: /accessKeyId.*secretAccessKey/}
+ );
+ });
+
+ it('throws when only secretAccessKey is provided', function () {
+ assert.throws(
+ () => new S3RedirectsStore({bucket: 'x', staticFileURLPrefix: 'content/data', secretAccessKey: 'shh'}),
+ {errorType: 'IncorrectUsageError', message: /accessKeyId.*secretAccessKey/}
+ );
+ });
+
+ it('throws when sessionToken is provided without the credential pair', function () {
+ assert.throws(
+ () => new S3RedirectsStore({bucket: 'x', staticFileURLPrefix: 'content/data', sessionToken: 'session'}),
+ {errorType: 'IncorrectUsageError', message: /accessKeyId.*secretAccessKey/}
+ );
+ });
+
+ it('accepts a tenantPrefix without throwing', function () {
+ assert.doesNotThrow(() => new S3RedirectsStore({bucket: 'x', staticFileURLPrefix: 'content/data', tenantPrefix: 'tenant-abc'}));
+ });
+ });
+});
diff --git a/ghost/core/test/unit/server/adapters/storage/index.test.js b/ghost/core/test/unit/server/adapters/storage/index.test.js
index 4922e53f742..745c272be91 100644
--- a/ghost/core/test/unit/server/adapters/storage/index.test.js
+++ b/ghost/core/test/unit/server/adapters/storage/index.test.js
@@ -68,11 +68,9 @@ describe('storage: index_spec', function () {
configUtils.set({
storage: {
active: 'broken-storage'
- },
- paths: {
- storage: __dirname + '/broken-storage.js'
}
});
+ configUtils.set('paths:storage', __dirname + '/broken-storage.js');
const jsFile = '' +
'\'use strict\';' +
diff --git a/ghost/core/test/unit/server/services/custom-redirects/gcs-store.test.ts b/ghost/core/test/unit/server/services/custom-redirects/gcs-store.test.ts
deleted file mode 100644
index a55ef41e82f..00000000000
--- a/ghost/core/test/unit/server/services/custom-redirects/gcs-store.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import assert from 'node:assert/strict';
-
-import {GCSStore} from '../../../../../core/server/services/custom-redirects/gcs-store';
-
-describe('UNIT: GCSStore', function () {
- describe('constructor validation', function () {
- it('throws when no bucket is provided', function () {
- assert.throws(
- () => new GCSStore({} as never),
- {errorType: 'IncorrectUsageError', message: /bucket/}
- );
- });
-
- it('throws when no S3 client is provided', function () {
- assert.throws(
- () => new GCSStore({bucket: 'x'} as never),
- {errorType: 'IncorrectUsageError', message: /S3 client/}
- );
- });
- });
-});
diff --git a/ghost/core/test/utils/minio.ts b/ghost/core/test/utils/minio.ts
index 10877c3b2d6..e1000960d89 100644
--- a/ghost/core/test/utils/minio.ts
+++ b/ghost/core/test/utils/minio.ts
@@ -12,16 +12,36 @@ import {
const DEFAULT_TEST_BUCKET_PREFIX = 'test-redirects';
-export function createTestS3Client(): S3Client {
- return new S3Client({
+export interface MinioTestConfig {
+ endpoint: string;
+ region: string;
+ forcePathStyle: boolean;
+ accessKeyId: string;
+ secretAccessKey: string;
+}
+
+// MinIO serves buckets via URL path (http://host/bucket/key) rather than
+// AWS's virtual-host style (https://bucket.s3.amazonaws.com/key), so
+// forcePathStyle stays true.
+export function getMinioConfig(): MinioTestConfig {
+ return {
endpoint: process.env.MINIO_TEST_ENDPOINT || 'http://127.0.0.1:9000',
region: process.env.MINIO_TEST_REGION || 'us-east-1',
- // MinIO serves buckets via URL path (http://host/bucket/key) rather than
- // AWS's virtual-host style (https://bucket.s3.amazonaws.com/key).
forcePathStyle: true,
+ accessKeyId: process.env.MINIO_TEST_ACCESS_KEY || 'minio-user',
+ secretAccessKey: process.env.MINIO_TEST_SECRET_KEY || 'minio-pass'
+ };
+}
+
+export function createTestS3Client(): S3Client {
+ const cfg = getMinioConfig();
+ return new S3Client({
+ endpoint: cfg.endpoint,
+ region: cfg.region,
+ forcePathStyle: cfg.forcePathStyle,
credentials: {
- accessKeyId: process.env.MINIO_TEST_ACCESS_KEY || 'minio-user',
- secretAccessKey: process.env.MINIO_TEST_SECRET_KEY || 'minio-pass'
+ accessKeyId: cfg.accessKeyId,
+ secretAccessKey: cfg.secretAccessKey
}
});
}
@@ -66,7 +86,7 @@ export async function putObject(
/**
* Returns null when the key is missing instead of throwing. Matches the
- * planned GCSStore.getAll() behaviour from HKG-1700 ("Returns [] on 404")
+ * planned S3RedirectsStore.getAll() behaviour from HKG-1700 ("Returns [] on 404")
* so tests asserting absence don't need try/catch wrappers.
*/
export async function getObject(