From 160bd6ce63dd58b166974afef30e0892e554e9a1 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Thu, 27 Feb 2025 09:10:18 +0000 Subject: [PATCH] Updated thread implementation to work with posts instead of activities refs [AP-721](https://linear.app/ghost/issue/AP-721/update-getactivitythread-to-work-with-posts-instead-of-activities) Updated thread implementation to work with posts instead of activities as part of the posts migration --- features/step_definitions/stepdefs.js | 47 ++++- features/thread.feature | 66 +++++++ src/app.ts | 6 +- src/db.ts | 74 -------- src/http/api/__snapshots__/thread.json | 79 +++++++++ src/http/api/activities.ts | 98 +---------- src/http/api/index.ts | 1 + src/http/api/thread.ts | 46 +++++ src/http/api/thread.unit.test.ts | 145 ++++++++++++++++ src/post/post.repository.knex.ts | 227 +++++++++++++++++++++++++ 10 files changed, 614 insertions(+), 175 deletions(-) create mode 100644 features/thread.feature create mode 100644 src/http/api/__snapshots__/thread.json create mode 100644 src/http/api/thread.ts create mode 100644 src/http/api/thread.unit.test.ts diff --git a/features/step_definitions/stepdefs.js b/features/step_definitions/stepdefs.js index 27e7903f..1fde0004 100644 --- a/features/step_definitions/stepdefs.js +++ b/features/step_definitions/stepdefs.js @@ -595,8 +595,25 @@ When('we request the outbox', async function () { }); When('an authenticated request is made to {string}', async function (path) { + let requestPath = path; + + // If this is a request to the /thread/ endpoint, we need to replace the + // object name with the object ID as we don't have a way to know the object + // ID ahead of time + if (path.includes('/thread/')) { + const objectName = path.split('/').pop(); // Object name is the last part of the path + const object = this.objects[objectName]; + + if (object) { + requestPath = path.replace( + objectName, + encodeURIComponent(object.id), + ); + } + } + this.response = await fetchActivityPub( - `http://fake-ghost-activitypub${path}`, + `http://fake-ghost-activitypub${requestPath}`, { headers: { Accept: 'application/ld+json', @@ -1624,6 +1641,7 @@ When( const activity = await this.response.clone().json(); this.activities[replyName] = activity; + this.objects[replyName] = activity.object; } }, ); @@ -1717,3 +1735,30 @@ Then( } }, ); + +Then( + 'post {string} in the thread is {string}', + async function (postNumber, objectName) { + const responseJson = await this.response.clone().json(); + + const object = this.objects[objectName]; + const post = responseJson.posts[Number(postNumber) - 1]; + + assert(post, `Expected to find ${objectName} in thread`); + + assert( + post.url === object.id, + `Expected ${objectName} to be at position ${postNumber} in thread`, + ); + }, +); + +Then('the thread contains {string} posts', async function (string) { + const responseJson = await this.response.clone().json(); + + assert.equal( + responseJson.posts.length, + Number(string), + `Expected thread to contain ${string} posts, but got ${responseJson.posts.length}`, + ); +}); diff --git a/features/thread.feature b/features/thread.feature new file mode 100644 index 00000000..d700d218 --- /dev/null +++ b/features/thread.feature @@ -0,0 +1,66 @@ +@only +Feature: Thread + In order to see replies to a post + As a user + I want to request the thread for a post + + Background: + Given an Actor "Person(Alice)" + And a "Follow(Us)" Activity "Follow" by "Alice" + And "Alice" sends "Follow" to the Inbox + And "Follow" is in our Inbox + And we follow "Alice" + And the request is accepted + And a "Accept(Follow(Alice))" Activity "Accept" by "Alice" + And "Alice" sends "Accept" to the Inbox + And "Accept" is in our Inbox + And a "Note" Object "Article" by "Alice" + And a "Create(Article)" Activity "Create" by "Alice" + And "Alice" sends "Create" to the Inbox + And "Create" is in our Inbox + + Scenario: Retrieving the thread for a top level post + Given we reply "Reply1" to "Article" with the content + """ + This is a great article! + """ + And "Reply1" is in our Outbox + And we reply "Reply2" to "Article" with the content + """ + This is still a great article! + """ + And "Reply2" is in our Outbox + And we reply "Reply3" to "Reply1" with the content + """ + This is a great reply! + """ + And "Reply3" is in our Outbox + When an authenticated request is made to "/.ghost/activitypub/thread/Article" + Then the request is accepted + And the thread contains "3" posts + And post "1" in the thread is "Article" + And post "2" in the thread is "Reply1" + And post "3" in the thread is "Reply2" + + Scenario: Retrieving the thread for a reply to a post + Given we reply "Reply1" to "Article" with the content + """ + This is a great article! + """ + And "Reply1" is in our Outbox + And we reply "Reply2" to "Article" with the content + """ + This is still a great article! + """ + And "Reply2" is in our Outbox + And we reply "Reply3" to "Reply1" with the content + """ + This is a great reply! + """ + And "Reply3" is in our Outbox + When an authenticated request is made to "/.ghost/activitypub/thread/Reply3" + Then the request is accepted + And the thread contains "3" posts + And post "1" in the thread is "Article" + And post "2" in the thread is "Reply1" + And post "3" in the thread is "Reply3" diff --git a/src/app.ts b/src/app.ts index 13507238..d3431161 100644 --- a/src/app.ts +++ b/src/app.ts @@ -99,11 +99,11 @@ import { createGetProfileFollowersHandler, createGetProfileFollowingHandler, createGetProfileHandler, + createGetThreadHandler, createPostPublishedWebhookHandler, createSearchHandler, handleCreateNote, handleGetActivities, - handleGetActivityThread, handleGetProfilePosts, handleWebhookSiteChanged, } from './http/api'; @@ -916,8 +916,8 @@ app.get( spanWrapper(handleGetProfilePosts), ); app.get( - '/.ghost/activitypub/thread/:object_id', - spanWrapper(handleGetActivityThread), + '/.ghost/activitypub/thread/:post_ap_id', + spanWrapper(createGetThreadHandler(postRepository, accountService)), ); app.get( '/.ghost/activitypub/account/:handle', diff --git a/src/db.ts b/src/db.ts index 5c017943..352fa2f6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -141,32 +141,6 @@ export async function getActivityMetaWithoutJoin( return map; } -export async function getActivityChildren(activity: ActivityJsonLd) { - const objectId = activity.object.id; - - const results = await client - .select('value') - .from('key_value') - .where(function () { - // If inReplyTo is a string - this.where( - client.raw( - `JSON_EXTRACT(value, "$.object.inReplyTo") = "${objectId}"`, - ), - ); - - // If inReplyTo is an object - this.orWhere( - client.raw( - `JSON_EXTRACT(value, "$.object.inReplyTo.id") = "${objectId}"`, - ), - ); - }) - .andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`)); - - return results.map((result) => result.value); -} - export async function getActivityChildrenCount(activity: ActivityJsonLd) { const objectId = activity.object.id; @@ -210,51 +184,3 @@ export async function getRepostCount(activity: ActivityJsonLd) { return result[0].count; } - -export async function getActivityParents(activity: ActivityJsonLd) { - const parents: ActivityJsonLd[] = []; - - const getParent = async (objectId: string) => { - const result = await client - .select('value') - .from('key_value') - .where( - client.raw( - `JSON_EXTRACT(value, "$.object.id") = "${objectId}"`, - ), - ) - .andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`)); - - if (result.length === 1) { - const parent = result[0]; - - parents.unshift(parent.value); - - // inReplyTo can be a string or an object - const inReplyToId = - parent.value.object.inReplyTo?.id ?? - parent.value.object.inReplyTo; - - if (inReplyToId) { - await getParent(inReplyToId); - } - } - }; - - await getParent( - // inReplyTo can be a string or an object - activity.object.inReplyTo?.id ?? activity.object.inReplyTo, - ); - - return parents; -} - -export async function getActivityForObject(objectId: string) { - const result = await client - .select('value') - .from('key_value') - .where(client.raw(`JSON_EXTRACT(value, "$.object.id") = "${objectId}"`)) - .andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`)); - - return result[0].value; -} diff --git a/src/http/api/__snapshots__/thread.json b/src/http/api/__snapshots__/thread.json new file mode 100644 index 00000000..e5e85e8b --- /dev/null +++ b/src/http/api/__snapshots__/thread.json @@ -0,0 +1,79 @@ +{ + "posts": [ + { + "attachments": [], + "author": { + "avatarUrl": "https://example.com/avatars/foobar.png", + "handle": "foobar", + "id": "https://example.com/users/456", + "name": "Foo Bar", + "url": "https://example.com/users/456", + }, + "content": "Test Content 1", + "excerpt": "Test Excerpt 1", + "featureImageUrl": "https://example.com/feature-images/post-1.jpg", + "id": "https://example.com/.ghost/activitypub/article/aaa-bbb-ccc", + "likeCount": 0, + "likedByMe": false, + "publishedAt": "2025-02-27T15:40:00.000Z", + "readingTimeMinutes": 1, + "replyCount": 0, + "repostCount": 0, + "repostedBy": null, + "repostedByMe": false, + "title": "Test Post 1", + "type": 1, + "url": "https://example.com/posts/1", + }, + { + "attachments": [], + "author": { + "avatarUrl": "https://example.com/avatars/foobar.png", + "handle": "foobar", + "id": "https://example.com/users/456", + "name": "Foo Bar", + "url": "https://example.com/users/456", + }, + "content": "Test Content 2", + "excerpt": "Test Excerpt 2", + "featureImageUrl": "https://example.com/feature-images/post-2.jpg", + "id": "https://example.com/.ghost/activitypub/article/ddd-eee-fff", + "likeCount": 0, + "likedByMe": true, + "publishedAt": "2025-02-27T15:40:00.000Z", + "readingTimeMinutes": 1, + "replyCount": 0, + "repostCount": 0, + "repostedBy": null, + "repostedByMe": false, + "title": "Test Post 2", + "type": 1, + "url": "https://example.com/posts/2", + }, + { + "attachments": [], + "author": { + "avatarUrl": "https://example.com/avatars/foobar.png", + "handle": "foobar", + "id": "https://example.com/users/456", + "name": "Foo Bar", + "url": "https://example.com/users/456", + }, + "content": "Test Content 3", + "excerpt": "Test Excerpt 3", + "featureImageUrl": "https://example.com/feature-images/post-3.jpg", + "id": "https://example.com/.ghost/activitypub/article/ggg-hhh-iii", + "likeCount": 0, + "likedByMe": false, + "publishedAt": "2025-02-27T15:40:00.000Z", + "readingTimeMinutes": 1, + "replyCount": 0, + "repostCount": 0, + "repostedBy": null, + "repostedByMe": true, + "title": "Test Post 3", + "type": 1, + "url": "https://example.com/posts/3", + }, + ], +} \ No newline at end of file diff --git a/src/http/api/activities.ts b/src/http/api/activities.ts index d960af76..45439aa6 100644 --- a/src/http/api/activities.ts +++ b/src/http/api/activities.ts @@ -1,12 +1,6 @@ import { type AppContext, fedify } from '../../app'; -import { - getActivityChildren, - getActivityForObject, - getActivityMeta, - getActivityParents, -} from '../../db'; +import { getActivityMeta } from '../../db'; import { buildActivity } from '../../helpers/activitypub/activity'; -import { isUri } from '../../helpers/uri'; import { spanWrapper } from '../../instrumentation'; const GET_ACTIVITIES_DEFAULT_LIMIT = 10; @@ -259,93 +253,3 @@ export async function handleGetActivities(ctx: AppContext) { }, ); } - -interface ActivityJsonLd { - [key: string]: any; -} - -/** - * Handle a request for an activity thread - * - * @param ctx App context instance - */ -export async function handleGetActivityThread(ctx: AppContext) { - const db = ctx.get('db'); - const globaldb = ctx.get('globaldb'); - const logger = ctx.get('logger'); - const apCtx = fedify.createContext(ctx.req.raw as Request, { - db, - globaldb, - logger, - }); - - // Parse "object_id" from request parameters - // /thread/:object_id - const paramObjectId = ctx.req.param('object_id'); - const objectId = paramObjectId ? decodeURIComponent(paramObjectId) : ''; - - // If the provided objectId is invalid, return early - if (isUri(objectId) === false) { - return new Response(null, { status: 400 }); - } - - const activityJsonLd = await getActivityForObject(objectId); - - // If the activity can not be found, return early - if (activityJsonLd === undefined) { - return new Response(null, { status: 404 }); - } - - const items: ActivityJsonLd[] = [activityJsonLd]; - - // If the object is a string, fetch the object from the database. We need to - // do this because we need the inReplyTo property of the object to find the - // parent(s) and children of the activity - if (typeof activityJsonLd.object === 'string') { - const object = await globaldb.get([ - activityJsonLd.object, - ]); - - if (object) { - activityJsonLd.object = object; - } - } - - // Find children (replies) and append to the thread - const children = await getActivityChildren(activityJsonLd); - items.push(...children); - - // Find parent(s) and prepend to the thread - const parents = await getActivityParents(activityJsonLd); - items.unshift(...parents); - - // Build the activities so that they have all the data expected by the client - const likedRefs = (await db.get(['liked'])) || []; - const repostedRefs = (await db.get(['reposted'])) || []; - - const builtActivities = await Promise.all( - items.map((item) => - buildActivity( - item.id, - globaldb, - apCtx, - likedRefs, - repostedRefs, - true, - ), - ), - ); - - // Return the response - return new Response( - JSON.stringify({ - items: builtActivities, - }), - { - headers: { - 'Content-Type': 'application/json', - }, - status: 200, - }, - ); -} diff --git a/src/http/api/index.ts b/src/http/api/index.ts index cee382a7..88625473 100644 --- a/src/http/api/index.ts +++ b/src/http/api/index.ts @@ -4,4 +4,5 @@ export * from './feed'; export * from './note'; export * from './profile'; export * from './search'; +export * from './thread'; export * from './webhook'; diff --git a/src/http/api/thread.ts b/src/http/api/thread.ts new file mode 100644 index 00000000..0f79df97 --- /dev/null +++ b/src/http/api/thread.ts @@ -0,0 +1,46 @@ +import type { AccountService } from 'account/account.service'; +import type { AppContext } from '../../app'; +import type { KnexPostRepository } from '../../post/post.repository.knex'; +import { postToDTO } from './helpers/post'; + +/** + * Create a handler for a request for a thread + * + * @param postRepository Post repository instance + */ +export function createGetThreadHandler( + postRepository: KnexPostRepository, + accountService: AccountService, +) { + /** + * Handle a request for a thread + * + * @param ctx App context instance + */ + return async function handleGetThread(ctx: AppContext) { + const postApId = decodeURIComponent(ctx.req.param('post_ap_id')); + + const account = await accountService.getDefaultAccountForSite( + ctx.get('site'), + ); + + const posts = ( + await postRepository.getThreadByApId(postApId, account.id) + ).map(({ post, likedByAccount, repostedByAccount }) => { + return postToDTO(post, { + likedByMe: likedByAccount, + repostedByMe: repostedByAccount, + repostedBy: null, + }); + }); + + return new Response( + JSON.stringify({ + posts, + }), + { + status: 200, + }, + ); + }; +} diff --git a/src/http/api/thread.unit.test.ts b/src/http/api/thread.unit.test.ts new file mode 100644 index 00000000..c1dda7f7 --- /dev/null +++ b/src/http/api/thread.unit.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, it, vi } from 'vitest'; +import { expect } from 'vitest'; + +import { Account } from 'account/account.entity'; +import type { AccountService } from 'account/account.service'; +import type { AppContext } from 'app'; +import { Audience, Post, PostType } from 'post/post.entity'; +import type { KnexPostRepository } from 'post/post.repository.knex'; +import type { Site } from 'site/site.service'; +import { createGetThreadHandler } from './thread'; + +describe('Thread API', () => { + let accountService: AccountService; + let site: Site; + let account: Account; + let postRepository: KnexPostRepository; + + beforeEach(() => { + vi.setSystemTime(new Date('2025-02-27T15:40:00Z')); + + site = { + id: 123, + host: 'example.com', + webhook_secret: 'secret', + }; + account = Account.createFromData({ + id: 456, + uuid: '9ea8fcd3-ec80-4b97-b95c-e3d227ccbd01', + username: 'foobar', + name: 'Foo Bar', + bio: 'Just a foo bar', + avatarUrl: new URL('https://example.com/avatars/foobar.png'), + bannerImageUrl: new URL('https://example.com/banners/foobar.png'), + site, + apId: new URL('https://example.com/users/456'), + url: new URL('https://example.com/users/456'), + }); + accountService = { + getDefaultAccountForSite: async (_site: Site) => { + if (_site === site) { + return account; + } + + return null; + }, + } as unknown as AccountService; + postRepository = {} as KnexPostRepository; + }); + + it('should return a thread', async () => { + const postApId = 'https://example.com/posts/1'; + + const ctx = { + req: { + param: (key: string) => { + if (key === 'post_ap_id') { + return postApId; + } + return null; + }, + }, + get: (key: string) => { + if (key === 'site') { + return site; + } + }, + } as unknown as AppContext; + + postRepository.getThreadByApId = vi + .fn() + .mockImplementation((_postApId, accountId) => { + if (_postApId === postApId && accountId === account.id) { + return [ + { + post: new Post( + 123, + 'aaa-bbb-ccc', + account, + PostType.Article, + Audience.Public, + 'Test Post 1', + 'Test Excerpt 1', + 'Test Content 1', + new URL('https://example.com/posts/1'), + new URL( + 'https://example.com/feature-images/post-1.jpg', + ), + new Date(), + ), + likedByAccount: false, + repostedByAccount: false, + }, + { + post: new Post( + 456, + 'ddd-eee-fff', + account, + PostType.Article, + Audience.Public, + 'Test Post 2', + 'Test Excerpt 2', + 'Test Content 2', + new URL('https://example.com/posts/2'), + new URL( + 'https://example.com/feature-images/post-2.jpg', + ), + new Date(), + ), + likedByAccount: true, + repostedByAccount: false, + }, + { + post: new Post( + 789, + 'ggg-hhh-iii', + account, + PostType.Article, + Audience.Public, + 'Test Post 3', + 'Test Excerpt 3', + 'Test Content 3', + new URL('https://example.com/posts/3'), + new URL( + 'https://example.com/feature-images/post-3.jpg', + ), + new Date(), + ), + likedByAccount: false, + repostedByAccount: true, + }, + ]; + } + return []; + }); + + const handler = createGetThreadHandler(postRepository, accountService); + + const response = await handler(ctx); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchFileSnapshot( + './__snapshots__/thread.json', + ); + }); +}); diff --git a/src/post/post.repository.knex.ts b/src/post/post.repository.knex.ts index e76f0c62..4d1ad4b2 100644 --- a/src/post/post.repository.knex.ts +++ b/src/post/post.repository.knex.ts @@ -10,6 +10,12 @@ import { PostDerepostedEvent } from './post-dereposted.event'; import { PostRepostedEvent } from './post-reposted.event'; import { Post } from './post.entity'; +interface ThreadPost { + post: Post; + likedByAccount: boolean; + repostedByAccount: boolean; +} + export class KnexPostRepository { constructor( private readonly db: Knex, @@ -108,6 +114,227 @@ export class KnexPostRepository { return post; } + /** + * Get a thread of posts by AP ID + * + * A thread should include all ancestors (the entire chain of parent posts) + * and all immediate children (direct replies) of the given post + * + * For example, if we have the following posts: + * + * ```text + * POST 1 + * POST 1.1 (child of POST 1) + * POST 1.2 (child of POST 1) + * POST 1.2.1 (child of POST 1.2) + * POST 1.2.2 (child of POST 1.2) + * POST 1.2.2.1 (child of POST 1.2.2) + * POST 1.2.3 (child of POST 1.2) + * POST 2 + * POST 2.1 (child of POST 2) + * POST 2.1.1 (child of POST 2.1) + * POST 3 + * ``` + * + * If we request a thread for POST 1 we should get back: + * + * ```text + * POST 1 (requested post) + * POST 1.1 (immediate child) + * POST 1.2 (immediate child) + * ``` + * + * If we request a thread for post 1.2.2 we should get back: + * + * ```text + * POST 1 (root ancestor) + * POST 1.2 (immediate parent) + * POST 1.2.2 (requested post) + * POST 1.2.2.1 (immediate child) + * ``` + * + * If we request a thread for post 2.1.1 we should get back: + * + * ```text + * POST 2 (root ancestor) + * POST 2.1 (immediate parent) + * POST 2.1.1 (requested post) + * ``` + * + * @param apId AP ID of the post to get the thread for + * @param accountId ID of the account to resolve post metadata for (i.e is + * the post liked by the account, is the post reposted by the account, etc) + */ + async getThreadByApId( + apId: string, + accountId: number, + ): Promise { + // Get the post for the given AP ID + const post = await this.db('posts') + .select('id', 'in_reply_to') + .where('ap_id', apId) + .first(); + + if (!post) { + return []; + } + + const postIdsForThread = []; + + // Recursively find the parent posts of the given post + // and add them to the thread in reverse order so that we can + // eventually return the thread in the correct order + let nextParentId = post.in_reply_to; + + while (nextParentId) { + const parent = await this.db('posts') + .select('in_reply_to') + .where('id', nextParentId) + .first(); + + if (parent) { + postIdsForThread.unshift(nextParentId); + } + + nextParentId = parent.in_reply_to; + } + + // Add the given post to the thread + postIdsForThread.push(post.id); + + // Find all the posts that are immediate children of the given post + for (const row of await this.db('posts') + .select('id') + .where('in_reply_to', post.id)) { + postIdsForThread.push(row.id); + } + + // Get all the posts that are in the thread + const thread = await this.db('posts') + .select( + // Post fields + 'posts.id', + 'posts.uuid', + 'posts.type', + 'posts.audience', + 'posts.title', + 'posts.excerpt', + 'posts.content', + 'posts.url', + 'posts.image_url', + 'posts.published_at', + 'posts.like_count', + 'posts.repost_count', + 'posts.reply_count', + 'posts.reading_time_minutes', + 'posts.attachments', + 'posts.author_id', + 'posts.ap_id', + 'posts.in_reply_to', + 'posts.thread_root', + // Author account fields + 'accounts.username', + 'accounts.uuid as author_uuid', + 'accounts.name', + 'accounts.bio', + 'accounts.avatar_url', + 'accounts.banner_image_url', + 'accounts.ap_id as author_ap_id', + 'accounts.url as author_url', + // Account metadata fields + this.db.raw(` + CASE + WHEN likes.account_id IS NOT NULL THEN 1 + ELSE 0 + END AS liked_by_account + `), + this.db.raw(` + CASE + WHEN reposts.account_id IS NOT NULL THEN 1 + ELSE 0 + END AS reposted_by_account + `), + ) + .join('accounts', 'accounts.id', 'posts.author_id') + .leftJoin('likes', function () { + this.on('likes.post_id', 'posts.id').andOnVal( + 'likes.account_id', + '=', + accountId, + ); + }) + .leftJoin('reposts', function () { + this.on('reposts.post_id', 'posts.id').andOnVal( + 'reposts.account_id', + '=', + accountId, + ); + }) + .whereIn('posts.id', postIdsForThread) + .orderBy('posts.published_at', 'asc'); + + const posts = []; + + for (const row of thread) { + if (!row.author_uuid) { + row.author_uuid = randomUUID(); + await this.db('accounts') + .update({ uuid: row.author_uuid }) + .where({ id: row.author_id }); + } + + const author = new Account( + row.author_id, + row.author_uuid, + row.username, + row.name, + row.bio, + parseURL(row.avatar_url), + parseURL(row.banner_image_url), + null, + parseURL(row.author_ap_id), + parseURL(row.author_url), + ); + + const attachments = row.attachments + ? row.attachments.map((attachment: any) => ({ + ...attachment, + url: new URL(attachment.url), + })) + : []; + + const post = new Post( + row.id, + row.uuid, + author, + row.type, + row.audience, + row.title, + row.excerpt, + row.content, + new URL(row.url), + parseURL(row.image_url), + new Date(row.published_at), + row.like_count, + row.repost_count, + row.reply_count, + row.in_reply_to, + row.thread_root, + row.reading_time_minutes, + attachments, + new URL(row.ap_id), + ); + + posts.push({ + post, + likedByAccount: row.liked_by_account === 1, + repostedByAccount: row.reposted_by_account === 1, + }); + } + + return posts; + } + /** * Save a post to the database *