diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index 211f2b9b866..d1ac1219af7 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -63,10 +63,6 @@ const features: Feature[] = [{ title: 'Smarter Counts', description: 'Use optimized COUNT queries for API pagination when safe', flag: 'smarterCounts' -}, { - title: 'Gift Subscriptions', - description: 'Allow site visitors to purchase gift subscriptions for others', - flag: 'giftSubscriptions' }, { title: 'Comments Threads', description: 'Enable deeper threading view in Comments-UI', diff --git a/apps/posts/src/views/comments/components/comment-content.tsx b/apps/posts/src/views/comments/components/comment-content.tsx index bbe1fd50114..9bc5a3d5b57 100644 --- a/apps/posts/src/views/comments/components/comment-content.tsx +++ b/apps/posts/src/views/comments/components/comment-content.tsx @@ -42,7 +42,7 @@ function CommentContent({item}: {item: Comment}) { }, [item.html, isExpanded]); return ( -
+
+
@@ -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(