Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Activity pub outbox #20163

Closed
wants to merge 39 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d9d6ef2
Added body parsing
allouis May 6, 2024
485eb3d
Updated HTTPSignature
allouis May 9, 2024
19575c8
Changed Activity to an Entity
allouis May 9, 2024
52b980a
Added basic repository for Activities
allouis May 9, 2024
ae03586
Added ability to Follow and Accept to Actors
allouis May 9, 2024
d9ec3f9
Added URI object
allouis May 9, 2024
f91a996
Added support for fingering
allouis May 9, 2024
44593f8
Added initial wire up of activity delivery
allouis May 9, 2024
460f030
Updated types
allouis May 9, 2024
8a965a8
Added inbox service for handling incoming activities
allouis May 9, 2024
69821f0
Wired up inbox endpoint
allouis May 9, 2024
7b591a2
fixup! Added ability to Follow and Accept to Actors
allouis May 9, 2024
d3d3953
fixup! Added ability to Follow and Accept to Actors
allouis May 9, 2024
5f51977
Updated Article Object to have objectId
allouis May 9, 2024
86501d9
Added activitypub service for following
allouis May 9, 2024
bace3e1
Wired up admin api for activitypub and following
allouis May 9, 2024
fdf77f9
FIXME - Wired up new stuff to Nest
allouis May 9, 2024
e7c862f
Added logging to global exception filter
allouis May 9, 2024
2684d13
Added feed for showing publication's own outbox articles
djordjevlais May 7, 2024
91f5276
Added todos
djordjevlais May 8, 2024
3f4a397
Fetching site url
vershwal May 8, 2024
856f0fe
Added static design for ActivityPub skateboard
djordjevlais May 8, 2024
c9f2b80
Updated test
djordjevlais May 8, 2024
00250fe
Updated dependencies
djordjevlais May 9, 2024
a57eca1
Initial support for display name
allouis May 9, 2024
514ecc4
fixup! FIXME - Wired up new stuff to Nest
allouis May 10, 2024
d792d2a
Used site title as display name
allouis May 10, 2024
8c65e02
Included username in followers
allouis May 10, 2024
fcb8bd7
Wired up following to API
allouis May 10, 2024
7de21f2
Used actor username when listing outbox
allouis May 10, 2024
82efc18
Added Follow mutation
allouis May 13, 2024
b2f0ca0
Connected Follow modal
djordjevlais May 13, 2024
2d0c7c2
Fixed error
djordjevlais May 13, 2024
400b274
Added more tests
allouis May 13, 2024
d00c4b2
Fixed passing username to follow mutation
allouis May 13, 2024
f06def5
Removed default following
allouis May 13, 2024
8877665
Returned empty object from follow endpoint
allouis May 13, 2024
9d02081
Added succes and error states
djordjevlais May 14, 2024
e280e66
fixing data stored in following
allouis May 14, 2024
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
13 changes: 10 additions & 3 deletions apps/admin-x-activitypub/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ListIndex from './components/ListIndex';
import ActivityPubComponent from './components/ListIndex';
import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system';
import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
Expand All @@ -8,12 +8,19 @@ interface AppProps {
designSystem: DesignSystemAppProps;
}

const modals = {
paths: {
'follow-site': 'FollowSite'
},
load: async () => import('./components/modals')
};

const App: React.FC<AppProps> = ({framework, designSystem}) => {
return (
<FrameworkProvider {...framework}>
<RoutingProvider basePath='activitypub'>
<RoutingProvider basePath='activitypub' modals={modals}>
<DesignSystemApp className='admin-x-activitypub' {...designSystem}>
<ListIndex />
<ActivityPubComponent />
</DesignSystemApp>
</RoutingProvider>
</FrameworkProvider>
Expand Down
72 changes: 72 additions & 0 deletions apps/admin-x-activitypub/src/components/FollowSite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import NiceModal from '@ebay/nice-modal-react';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useFollow} from '@tryghost/admin-x-framework/api/activitypub';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useState} from 'react';

const FollowSite = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const mutation = useFollow();

// mutation.isPending
// mutation.isError
// mutation.isSuccess
// mutation.mutate({username: '@index@site.com'})
// mutation.reset();

// State to manage the text field value
const [profileName, setProfileName] = useState('');
// const [success, setSuccess] = useState(false);
const [errorMessage, setError] = useState(null);

const handleFollow = async () => {
try {
// Perform the mutation
await mutation.mutateAsync({username: profileName});
// If successful, set the success state to true
// setSuccess(true);
showToast({
message: 'Site followed',
type: 'success'
});
modal.remove();
} catch (error) {
// If there's an error, set the error state
setError(errorMessage);
}
};

return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
}}
cancelLabel='Cancel'
okLabel='Follow'
size='sm'
title='Follow a Ghost site'
onOk={handleFollow}
// onOk={() => {
// mutation.mutate({username: profileName});
// updateRoute('');
// modal.remove();
// }}
>
<div className='mt-3 flex flex-col gap-4'>
<TextField
autoFocus={true}
error={Boolean(errorMessage)}
hint={errorMessage}
placeholder='@username@hostname'
title='Profile name'
value={profileName}
onChange={e => setProfileName(e.target.value)}
/>
</div>
</Modal>
);
});

export default FollowSite;
199 changes: 179 additions & 20 deletions apps/admin-x-activitypub/src/components/ListIndex.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,185 @@
const ListIndex = () => {
import React, {useEffect, useState} from 'react';
import {Heading, Icon, Page, ViewContainer} from '@tryghost/admin-x-design-system';
import {SiteData, useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useRouting} from '@tryghost/admin-x-framework/routing';

interface Activity {
id: string;
type: string;
summary: string;
actor: string;
object: string;
siteData: SiteData;
}

type Following = {
id: string;
username?: string;
}

interface ObjectContent {
type: string;
name: string;
content: string;
url: string;
}

const ActivityPubComponent: React.FC = () => {
const [activities, setActivities] = useState<Activity[]>([]);
const [following, setFollowing] = useState<Following[]>([]);
const site = useBrowseSite();
const siteData = site.data?.site;
const {updateRoute} = useRouting();

useEffect(() => {
const fetchActivities = async () => {
try {
const response = await fetch(`${siteData?.url.replace(/\/$/, '')}/activitypub/outbox/deadbeefdeadbeefdeadbeef`);
// console.log('Fetching activities from:', siteData?.url.replace(/\/$/, '') + '/activitypub/outbox/deadbeefdeadbeefdeadbeef');

if (response.ok) {
const data = await response.json();
setActivities(data.orderedItems);
} else {
throw new Error('Failed to fetch activities');
}
} catch (error) {
// console.error('Error fetching activities:', error);
}
};

fetchActivities();

// Clean up function if needed
return () => {
// Any clean-up code here
};
}, [siteData]);

useEffect(() => {
const fetchFollowing = async () => {
try {
const response = await fetch(`${siteData?.url.replace(/\/$/, '')}/activitypub/following/deadbeefdeadbeefdeadbeef`);
// console.log('Fetching activities from:', siteData?.url.replace(/\/$/, '') + '/activitypub/outbox/deadbeefdeadbeefdeadbeef');

if (response.ok) {
const data = await response.json();
setFollowing(data);
} else {
throw new Error('Failed to fetch following');
}
} catch (error) {
// console.error('Error fetching activities:', error);
}
};

fetchFollowing();

// Clean up function if needed
return () => {
// Any clean-up code here
};
}, [siteData]);

return (
<div className='mx-auto my-0 w-full max-w-3xl p-12'>
<h1 className='mb-6 text-black'>ActivityPub Demo</h1>
<div className='flex flex-col'>
<div className='mb-4 flex flex-col'>
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
<p className='text-md text-grey-700'>Publish McPublisher</p>
</div>
<div className='mb-4 flex flex-col'>
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
<p className='text-md text-grey-700'>Publish McPublisher</p>
</div>
<div className='mb-4 flex flex-col'>
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
<p className='text-md text-grey-700'>Publish McPublisher</p>
<Page>
<ViewContainer
// actions={dummyActions}
primaryAction={{
title: 'Follow',
onClick: () => {
updateRoute('follow-site');
},
icon: 'add'
}}
title='Outbox'
toolbarBorder={false}
type='page'
>
<div className='grid grid-cols-6 items-start gap-6'>
<ul className='col-span-4 flex flex-col'>
{activities.slice().reverse().map(activity => (
<li key={activity.id}>
{/* <p className='text-grey-700'>Activity Type: {activity.type}</p>
<p className='text-grey-700'>Summary: {activity.summary}</p>
<p className='text-grey-700'>Actor: {activity.actor}</p> */}
<ObjectContentDisplay actor={activity.actor} objectUrl={activity.object} />
</li>
))}
</ul>
<div className='col-span-2 rounded-xl bg-grey-50 p-5'>
<Heading className='mb-3' level={5}>Following</Heading>
<ul>
{following.slice().map(({username}) => {
return (<li className='mb-4'>
<span className='mb-2 text-md font-medium text-grey-800'>{username}</span>
</li>);
})}
</ul>
</div>
</div>
</div>
</ViewContainer>
</Page>
);
};

const ArticleBody: React.FC<{html: string}> = ({html}) => {
const dangerouslySetInnerHTML = {__html: html};
return (
<div className="mt mb-2 flex flex-row items-center gap-4 pr-4">
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content text-neutral-900 font-sans text-[16px] leading-normal [overflow-wrap:anywhere] dark:text-[rgba(255,255,255,0.85)]" data-testid="comment-content"/>
</div>
);
};

export default ListIndex;
const ObjectContentDisplay: React.FC<{actor: string, objectUrl: string }> = ({actor, objectUrl}) => {
const [objectContent, setObjectContent] = useState<ObjectContent | null>(null);

useEffect(() => {
const fetchObjectContent = async () => {
try {
const response = await fetch(objectUrl);
if (response.ok) {
const data = await response.json();
setObjectContent(data);
} else {
throw new Error('Failed to fetch object content');
}
} catch (error) {
// console.error('Error fetching object content:', error);
}
};

fetchObjectContent();

// Clean up function if needed
return () => {
// Any clean-up code here
};
}, [objectUrl]);

const parser = new DOMParser();
const doc = parser.parseFromString(objectContent?.content || '', 'text/html');

const plainTextContent = doc.body.textContent;

return (
<>
{objectContent && (
<a className='border-1 group/article flex flex-col items-start justify-between border-b border-b-grey-200 py-5' href={`${objectContent.url}`} rel="noopener noreferrer" target="_blank">
{/* <p className='mb-2 text-grey-700'>Object Type: {objectContent.type}</p> */}
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-2' level={5}>{objectContent.name}</Heading>
<Icon className='mb-2 opacity-0 transition-opacity group-hover/article:opacity-100' colorClass='text-grey-500' name='arrow-top-right' size='sm' />
</div>
<ArticleBody html={objectContent?.content} />
<p className='mb-6 line-clamp-2 max-w-prose text-md text-grey-800'>{plainTextContent}</p>
{/* <p className='mb-2 text-grey-950'>{objectContent.url}</p> */}
<p className='text-md font-medium text-grey-800'>{actor}</p>
</a>
)}
</>
);
};

export default ActivityPubComponent;
9 changes: 9 additions & 0 deletions apps/admin-x-activitypub/src/components/modals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import FollowSite from './FollowSite';
import {ModalComponent} from '@tryghost/admin-x-framework/routing';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modals = {FollowSite} satisfies {[key: string]: ModalComponent<any>};

export default modals;

export type ModalName = keyof typeof modals;
4 changes: 4 additions & 0 deletions apps/admin-x-activitypub/src/styles/index.css
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
@import '@tryghost/admin-x-design-system/styles.css';

.admin-x-base.admin-x-activitypub {
animation-name: none;
}
2 changes: 1 addition & 1 deletion apps/admin-x-activitypub/test/unit/ListIndex.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ describe('Demo', function () {
it('renders a component', async function () {
render(<ListIndex/>);

expect(screen.getAllByRole('heading')[0].textContent).toEqual('ActivityPub Demo');
expect(screen.getAllByRole('heading')[0].textContent).toEqual('Outbox');
});
});
10 changes: 10 additions & 0 deletions apps/admin-x-framework/src/api/activitypub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {createMutation} from '../utils/api/hooks';

type FollowData = {
username: string
};

export const useFollow = createMutation<unknown, FollowData>({
method: 'POST',
path: data => `/activitypub/follow/${data.username}`
});
16 changes: 16 additions & 0 deletions ghost/core/core/frontend/web/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const shared = require('../../server/web/shared');
const errorHandler = require('@tryghost/mw-error-handler');
const mw = require('./middleware');
const labs = require('../../shared/labs');
const bodyParser = require('body-parser');

const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_URL_PREFIX}`;
const STATIC_MEDIA_URL_PREFIX = `/${constants.STATIC_MEDIA_URL_PREFIX}`;
Expand Down Expand Up @@ -50,6 +51,21 @@ module.exports = function setupSiteApp(routerConfig) {
// enable CORS headers (allows admin client to hit front-end when configured on separate URLs)
siteApp.use(mw.cors);

const jsonParser = bodyParser.json({
type: ['application/activity+json', 'application/ld+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'],
// TODO: The @RawBody decorator in nest isn't working without this atm...
verify: function (req, res, buf) {
req.rawBody = buf;
}
});
siteApp.use(async function nestBodyParser(req, res, next) {
if (labs.isSet('NestPlayground') || labs.isSet('ActivityPub')) {
jsonParser(req, res, next);
return;
}
return next();
});

siteApp.use(async function nestApp(req, res, next) {
if (labs.isSet('NestPlayground') || labs.isSet('ActivityPub')) {
const originalExpressApp = req.app;
Expand Down
2 changes: 1 addition & 1 deletion ghost/ghost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@types/sinon": "^17.0.3",
"c8": "8.0.1",
"mocha": "10.2.0",
"reflect-metadata": "0.1.13",
"reflect-metadata": "^0.1.14",
"sinon": "^17.0.1",
"ts-node": "10.9.2",
"typescript": "5.4.5"
Expand Down
2 changes: 1 addition & 1 deletion ghost/ghost/src/common/types/settings-cache.type.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type Settings = {
ghost_public_key: string;
ghost_private_key: string;
testing: boolean;
title: string;
};

export interface SettingsCache {
Expand Down
Loading
Loading