Skip to content

Commit

Permalink
Ensure only public content is posted (#258)
Browse files Browse the repository at this point in the history
refs [AP-641](https://linear.app/ghost/issue/AP-641/only-public-posts-should-be-sent-to-the-fediverse)

Ensured that only public content is posted when the visibility of an incoming post is not `public`
  • Loading branch information
mike182uk authored Jan 13, 2025
1 parent eb0af84 commit b2cd533
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 47 deletions.
3 changes: 3 additions & 0 deletions src/http/api/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ACTOR_DEFAULT_HANDLE } from '../../constants';
import { updateSiteActor } from '../../helpers/activitypub/actor';
import { getSiteSettings } from '../../helpers/ghost';
import { publishPost } from '../../publishing/helpers';
import { PostVisibility } from '../../publishing/types';

const PostSchema = z.object({
uuid: z.string().uuid(),
Expand All @@ -15,6 +16,7 @@ const PostSchema = z.object({
feature_image: z.string().url().nullable(),
published_at: z.string().datetime(),
url: z.string().url(),
visibility: z.nativeEnum(PostVisibility),
});

type Post = z.infer<typeof PostSchema>;
Expand Down Expand Up @@ -55,6 +57,7 @@ export async function handleWebhookPostPublished(ctx: AppContext) {
author: {
handle: ACTOR_DEFAULT_HANDLE,
},
visibility: data.visibility,
});
} catch (err) {
ctx.get('logger').error('Failed to publish post: {error}', {
Expand Down
7 changes: 5 additions & 2 deletions src/publishing/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ import {
FedifyUriBuilder,
} from '../activitypub';
import { type AppContext, fedify } from '../app';
import { FedifyPublishingService, type Post } from './service';
import { FedifyPublishingService } from './service';
import type { Post } from './types';

/**
* Publishes a post to the Fediverse
* Publish a post to the Fediverse
*
* @param ctx App context instance
* @param post Post to publish
*/
export async function publishPost(ctx: AppContext, post: Post) {
const scopedDb = ctx.get('db');
const globalDb = ctx.get('globaldb');
const logger = ctx.get('logger');

const fedifyCtx = fedify.createContext(ctx.req.raw, {
db: scopedDb,
Expand All @@ -27,6 +29,7 @@ export async function publishPost(ctx: AppContext, post: Post) {
const publishingService = new FedifyPublishingService(
new FedifyActivitySender(fedifyCtx),
new FedifyActorResolver(fedifyCtx),
logger,
new FedifyKvStoreObjectStore(globalDb),
new FedifyUriBuilder(fedifyCtx),
);
Expand Down
77 changes: 34 additions & 43 deletions src/publishing/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
Note,
PUBLIC_COLLECTION,
} from '@fedify/fedify';
import type { Temporal } from '@js-temporal/polyfill';
import type { Logger } from '@logtape/logtape';
import { v4 as uuidv4 } from 'uuid';

import type {
Expand All @@ -17,56 +17,19 @@ import type {
Outbox,
UriBuilder,
} from '../activitypub';
import { type Post, PostVisibility } from './types';

/**
* Post to be published to the Fediverse
* Marker to indicate that content proceeding is not public
*/
export interface Post {
/**
* Unique identifier of the post
*/
id: string;
/**
* Title of the post
*/
title: string;
/**
* Content of the post
*/
content: string | null;
/**
* Excerpt of the post
*/
excerpt: string | null;
/**
* URL to the post's feature image
*/
featureImageUrl: URL | null;
/**
* Published date of the post
*/
publishedAt: Temporal.Instant;
/**
* URL to the post
*/
url: URL;
/**
* Information about the post's author
*/
author: {
/**
* The author's Fediverse handle
*/
handle: string;
};
}
export const POST_CONTENT_NON_PUBLIC_MARKER = '<!--members-only-->';

/**
* Publishes content to the Fediverse
*/
export interface PublishingService {
/**
* Publishes a post to the Fediverse
* Publish a post to the Fediverse
*
* @param post Post to publish
* @param outbox Outbox to record the published post in
Expand All @@ -81,6 +44,7 @@ export class FedifyPublishingService implements PublishingService {
constructor(
private readonly activitySender: ActivitySender<Activity, Actor>,
private readonly actorResolver: ActorResolver<Actor>,
private readonly logger: Logger,
private readonly objectStore: ObjectStore<FedifyObject>,
private readonly uriBuilder: UriBuilder<FedifyObject>,
) {}
Expand All @@ -100,6 +64,33 @@ export class FedifyPublishingService implements PublishingService {
);
}

// Compute the content to use for the article
const isPublic = post.visibility === PostVisibility.Public;
let articleContent = post.content;

if (isPublic === false && post.content !== null) {
articleContent = '';

const nonPublicContentIdx = post.content.indexOf(
POST_CONTENT_NON_PUBLIC_MARKER,
);
if (nonPublicContentIdx !== -1) {
articleContent = post.content.substring(0, nonPublicContentIdx);
}

// If there is no public content, do not publish the post
if (articleContent === '') {
this.logger.info(
'Skipping publishing post: No public content found for post: {post}',
{
post,
},
);

return;
}
}

// Build the required objects
const preview = new Note({
id: this.uriBuilder.buildObjectUri(Note, post.id),
Expand All @@ -109,7 +100,7 @@ export class FedifyPublishingService implements PublishingService {
id: this.uriBuilder.buildObjectUri(Article, post.id),
attribution: actor,
name: post.title,
content: post.content,
content: articleContent,
image: post.featureImageUrl,
published: post.publishedAt,
preview,
Expand Down
83 changes: 81 additions & 2 deletions src/publishing/service.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Person,
} from '@fedify/fedify';
import { Temporal } from '@js-temporal/polyfill';
import type { Logger } from '@logtape/logtape';

import type {
ActivitySender,
Expand All @@ -18,8 +19,11 @@ import type {
Outbox,
UriBuilder,
} from '../activitypub';

import { FedifyPublishingService, type Post } from './service';
import {
FedifyPublishingService,
POST_CONTENT_NON_PUBLIC_MARKER,
} from './service';
import { type Post, PostVisibility } from './types';

vi.mock('uuid', () => ({
// Return a fixed UUID for deterministic testing
Expand All @@ -31,6 +35,7 @@ describe('FedifyPublishingService', () => {
let mockActivitySender: ActivitySender<Activity, Actor>;
let actor: Actor;
let mockActorResolver: ActorResolver<Actor>;
let mockLogger: Logger;
let mockObjectStore: ObjectStore<FedifyObject>;
let mockUriBuilder: UriBuilder<FedifyObject>;
let mockOutbox: Outbox<Activity>;
Expand All @@ -50,6 +55,10 @@ describe('FedifyPublishingService', () => {
resolveActorByHandle: vi.fn().mockResolvedValue(actor),
} as ActorResolver<Actor>;

mockLogger = {
info: vi.fn().mockResolvedValue(void 0),
} as unknown as Logger;

mockObjectStore = {
store: vi.fn().mockResolvedValue(void 0),
} as ObjectStore<FedifyObject>;
Expand Down Expand Up @@ -85,6 +94,7 @@ describe('FedifyPublishingService', () => {
),
publishedAt: Temporal.Instant.from('2025-01-12T10:30:00.000Z'),
url: new URL(`https://example.com/post/${postId}`),
visibility: PostVisibility.Public,
author: {
handle,
},
Expand All @@ -99,6 +109,7 @@ describe('FedifyPublishingService', () => {
const service = new FedifyPublishingService(
mockActivitySender,
mockActorResolver,
mockLogger,
mockObjectStore,
mockUriBuilder,
);
Expand All @@ -112,6 +123,7 @@ describe('FedifyPublishingService', () => {
const service = new FedifyPublishingService(
mockActivitySender,
mockActorResolver,
mockLogger,
mockObjectStore,
mockUriBuilder,
);
Expand All @@ -137,6 +149,7 @@ describe('FedifyPublishingService', () => {
const service = new FedifyPublishingService(
mockActivitySender,
mockActorResolver,
mockLogger,
mockObjectStore,
mockUriBuilder,
);
Expand All @@ -154,6 +167,7 @@ describe('FedifyPublishingService', () => {
const service = new FedifyPublishingService(
mockActivitySender,
mockActorResolver,
mockLogger,
mockObjectStore,
mockUriBuilder,
);
Expand All @@ -175,5 +189,70 @@ describe('FedifyPublishingService', () => {
.calls[0][1],
).toBe(actor);
});

it('should ensure that non-public content is not included in the article content', async () => {
post.visibility = PostVisibility.Members;
post.content = `Public content${POST_CONTENT_NON_PUBLIC_MARKER}Non public content`;

const service = new FedifyPublishingService(
mockActivitySender,
mockActorResolver,
mockLogger,
mockObjectStore,
mockUriBuilder,
);

await service.publishPost(post, mockOutbox);

expect(
mockActivitySender.sendActivityToActorFollowers,
).toHaveBeenCalledTimes(1);

const sentActivity = vi.mocked(
mockActivitySender.sendActivityToActorFollowers,
).mock.calls[0][0];

expect((await sentActivity.getObject())?.content).toBe(
'Public content',
);
});

it('should not publish a post if there is no public content', async () => {
post.visibility = PostVisibility.Members;
post.content = 'Non public content';

const service = new FedifyPublishingService(
mockActivitySender,
mockActorResolver,
mockLogger,
mockObjectStore,
mockUriBuilder,
);

await service.publishPost(post, mockOutbox);

expect(
mockActivitySender.sendActivityToActorFollowers,
).not.toHaveBeenCalled();
});

it('should not publish a post if there is no public content prior to the non-public content marker', async () => {
post.visibility = PostVisibility.Members;
post.content = `${POST_CONTENT_NON_PUBLIC_MARKER}Non public content`;

const service = new FedifyPublishingService(
mockActivitySender,
mockActorResolver,
mockLogger,
mockObjectStore,
mockUriBuilder,
);

await service.publishPost(post, mockOutbox);

expect(
mockActivitySender.sendActivityToActorFollowers,
).not.toHaveBeenCalled();
});
});
});
70 changes: 70 additions & 0 deletions src/publishing/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Temporal } from '@js-temporal/polyfill';

/**
* Visibility of a post
*/
export enum PostVisibility {
/**
* Public post
*/
Public = 'public',
/**
* Members-only post
*/
Members = 'members',
/**
* Paid post
*/
Paid = 'paid',
/**
* Tiers post
*/
Tiers = 'tiers',
}

/**
* Post to be published to the Fediverse
*/
export interface Post {
/**
* Unique identifier of the post
*/
id: string;
/**
* Title of the post
*/
title: string;
/**
* Content of the post
*/
content: string | null;
/**
* Excerpt of the post
*/
excerpt: string | null;
/**
* URL to the post's feature image
*/
featureImageUrl: URL | null;
/**
* Published date of the post
*/
publishedAt: Temporal.Instant;
/**
* URL to the post
*/
url: URL;
/**
* Visibility of the post
*/
visibility: PostVisibility;
/**
* Information about the post's author
*/
author: {
/**
* The author's Fediverse handle
*/
handle: string;
};
}

0 comments on commit b2cd533

Please sign in to comment.