Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function CommentContent({item}: {item: Comment}) {
}, [item.html, isExpanded]);

return (
<div className={`mt-1 flex flex-col gap-2`}>
<div className={`mt-2 flex flex-col gap-2`}>
<div className={`flex max-w-full flex-col items-start ${item.status === 'hidden' && 'opacity-50'}`}>
<div
dangerouslySetInnerHTML={{__html: item.html || ''}}
Expand Down
13 changes: 6 additions & 7 deletions apps/posts/src/views/comments/components/comment-header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Badge, Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components';
import {Badge, Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, badgeVariants} from '@tryghost/shade/components';
import {LucideIcon, cn, formatTimestamp} from '@tryghost/shade/utils';
import type {MouseEvent} from 'react';

Expand Down Expand Up @@ -28,10 +28,9 @@ interface CommentHeaderProps {
className?: string;
}

const pinnedBadgeClassName = 'inline-flex items-center gap-1 rounded-full border border-amber-300/70 bg-amber-50 px-2 py-0.5 font-sans text-xs font-medium leading-none text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100';
const pinnedButtonClassName = cn(
pinnedBadgeClassName,
'hover:border-amber-400 hover:bg-amber-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 dark:hover:border-amber-400/50 dark:hover:bg-amber-400/20'
badgeVariants({variant: 'warning'}),
'gap-1 hover:bg-state-warning/30'
);

export function CommentHeader({
Expand All @@ -53,9 +52,9 @@ export function CommentHeader({
};

return (
<div className={cn('flex items-baseline gap-4', className)}>
<div className={cn('flex items-center gap-2', className)}>
<div className={cn(
'mb-1 flex min-w-0 items-center gap-x-1 text-sm',
'flex min-w-0 items-center gap-x-1 text-sm',
isHidden && 'opacity-50'
)}>
<div className='whitespace-nowrap'>
Expand Down Expand Up @@ -146,7 +145,7 @@ export function CommentHeader({
</span>
</button>
) : (
<Badge className={pinnedBadgeClassName} variant='outline'>
<Badge className='gap-1' variant='warning'>
<LucideIcon.Pin className="size-3" />
Pinned
</Badge>
Expand Down
10 changes: 9 additions & 1 deletion apps/shade/src/components/ui/badge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Badge>;
Expand Down Expand Up @@ -50,6 +50,13 @@ export const Success: Story = {
}
};

export const Warning: Story = {
args: {
variant: 'warning',
children: 'Warning'
}
};

export const Outline: Story = {
args: {
variant: 'outline',
Expand All @@ -64,6 +71,7 @@ export const AllVariants: Story = {
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Error</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="outline">Outline</Badge>
</div>
)
Expand Down
2 changes: 2 additions & 0 deletions apps/shade/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
},
Expand Down
9 changes: 8 additions & 1 deletion apps/shade/test/unit/components/ui/badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Badge variant="warning">Warning Badge</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(<Badge variant="outline">Outline Badge</Badge>);
const badge = screen.getByText('Outline Badge');
Expand All @@ -54,4 +61,4 @@ describe('Badge Component', () => {

assert.equal(badge.textContent, 'Test Badge', 'Should render the text content');
});
});
});
2 changes: 1 addition & 1 deletion ghost/admin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>;
}

export default RedirectsStoreBase;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = class RedirectsStoreBase {
constructor() {
Object.defineProperty(this, 'requiredFns', {
value: ['getAll', 'replaceAll'],
writable: false
});
}
};
163 changes: 163 additions & 0 deletions ghost/core/core/server/adapters/redirects/S3RedirectsStore.ts
Original file line number Diff line number Diff line change
@@ -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<RedirectConfig[]> {
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<void> {
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<boolean> {
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;
}
}
6 changes: 6 additions & 0 deletions ghost/core/core/server/services/adapter-manager/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
1 change: 1 addition & 0 deletions ghost/core/core/server/services/adapter-manager/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down
Loading