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

Implemented Ghost site changed webhook handler #5

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@sentry/node": "8.13.0",
"hono": "4.4.6",
"knex": "3.1.0",
"ky": "1.4.0",
"mysql2": "3.10.1",
"uuid": "10.0.0",
"x-forwarded-fetch": "0.2.0"
Expand Down
3 changes: 2 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
createDispatcher,
} from './dispatchers';

import { inboxHandler, postPublishedWebhook, followAction } from './handlers';
import { followAction, inboxHandler, postPublishedWebhook, siteChangedWebhook } from './handlers';

if (process.env.SENTRY_DSN) {
Sentry.init({ dsn: process.env.SENTRY_DSN });
Expand Down Expand Up @@ -212,6 +212,7 @@ app.use(async (ctx, next) => {

app.get('/.ghost/activitypub/inbox/:handle', inboxHandler);
app.post('/.ghost/activitypub/webhooks/post/published', postPublishedWebhook);
app.post('/.ghost/activitypub/webhooks/site/changed', siteChangedWebhook);
app.post('/.ghost/activitypub/actions/follow/:handle', followAction);

/** Federation wire up */
Expand Down
104 changes: 1 addition & 103 deletions src/dispatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,119 +2,17 @@ import {
Article,
Accept,
Follow,
Image,
Person,
RequestContext,
lookupObject,
generateCryptoKeyPair,
exportJwk,
importJwk,
Create,
Note,
Activity,
} from '@fedify/fedify';
import { v4 as uuidv4 } from 'uuid';
import { addToList } from './kv-helpers';
import { ContextData } from './app';

type PersonData = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed to make use of this type in the webhook handler, but felt weird importing it from dispatchers so extracted "user" functionality to a user.ts module. Not sure what convention we want to use for locating shared types etc.

id: string;
name: string;
summary: string;
preferredUsername: string;
icon: string;
inbox: string;
outbox: string;
following: string;
followers: string;
};

async function getUserData(ctx: RequestContext<ContextData>, handle: string) {
const existing = await ctx.data.db.get<PersonData>(['handle', handle]);

if (existing) {
let icon = null;
try {
icon = new Image({ url: new URL(existing.icon) });
} catch (err) {
console.log('Could not create Image from Icon value', existing.icon);
console.log(err);
}
return {
id: new URL(existing.id),
name: existing.name,
summary: existing.summary,
preferredUsername: existing.preferredUsername,
icon,
inbox: new URL(existing.inbox),
outbox: new URL(existing.outbox),
following: new URL(existing.following),
followers: new URL(existing.followers),
publicKeys: (await ctx.getActorKeyPairs(handle)).map(
(key) => key.cryptographicKey,
),
};
}

const data = {
id: ctx.getActorUri(handle),
name: `Local Ghost site`,
summary: 'This is a summary',
preferredUsername: handle,
icon: new Image({ url: new URL('https://ghost.org/favicon.ico') }),
inbox: ctx.getInboxUri(handle),
outbox: ctx.getOutboxUri(handle),
following: ctx.getFollowingUri(handle),
followers: ctx.getFollowersUri(handle),
publicKeys: (await ctx.getActorKeyPairs(handle)).map(
(key) => key.cryptographicKey,
),
};

const dataToStore: PersonData = {
id: data.id.href,
name: data.name,
summary: data.summary,
preferredUsername: data.preferredUsername,
icon: 'https://ghost.org/favicon.ico',
inbox: data.inbox.href,
outbox: data.outbox.href,
following: data.following.href,
followers: data.followers.href,
};

await ctx.data.db.set(['handle', handle], data);

return data;
}

async function getUserKeypair(ctx: ContextData, handle: string) {
const existing = await ctx.db.get<{ publicKey: any; privateKey: any }>([
'keypair',
handle,
]);

if (existing) {
return {
publicKey: await importJwk(existing.publicKey, 'public'),
privateKey: await importJwk(existing.privateKey, 'private'),
};
}

const keys = await generateCryptoKeyPair();

const data = {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
};

await ctx.db.set(['keypair', handle], {
publicKey: await exportJwk(data.publicKey),
privateKey: await exportJwk(data.privateKey),
});

return data;
}
import { getUserData, getUserKeypair } from './user';

export async function actorDispatcher(
ctx: RequestContext<ContextData>,
Expand Down
47 changes: 47 additions & 0 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@ import {
Note,
} from '@fedify/fedify';
import { Context, Next } from 'hono';
import ky from 'ky';
import { v4 as uuidv4 } from 'uuid';
import { addToList } from './kv-helpers';
import { toURL } from './toURL';
import { ContextData, HonoContextVariables, fedify } from './app';
import type { PersonData } from './user';

type GhostSiteSettings = {
site: {
description: string;
icon: string;
title: string;
}
}

async function postToArticle(ctx: RequestContext<ContextData>, post: any) {
if (!post) {
Expand Down Expand Up @@ -121,6 +131,43 @@ export async function postPublishedWebhook(
});
}

export async function siteChangedWebhook(
ctx: Context<{ Variables: HonoContextVariables }>,
next: Next,
) {
try {
// Retrieve site settings from Ghost
const host = ctx.req.header('host');

const settings = await ky
.get(`https://${host}/ghost/api/admin/site/`)
.json<GhostSiteSettings>();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is sus, but not sure what the best thing to do here is as we don't know anything about the requesting client (so am just assuming that the request came from the Ghost instance on that is accessible on the same host 🙃)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, agree this is sus! Maybe something like:

async function getGhostSiteSettings(host: string): Promise<GhostSiteSettings> {
  const url = new URL(`https://${host}/ghost/api/admin/site/`);
  const settings = await ky
            .get(`https://${host}/ghost/api/admin/site/`)
            .json<unknown>();
  
  // Some assertions here so TS doesn't complain about `unknown`
  
  return {
    site: {
      description: settings?.site?.description || 'This is a summary',
      title: settings?.site?.title || 'index',
      icon: settings?.site?.icon || 'https://ghost.org/favicon.ico',
    }
  };
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above couples site settings -> default actor data, which isn't great, but it's a step forward. Would be better to have null as the default instead and then default in the handler probs

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me the sus part was using the host header and assuming thats where the original request was coming from in order to make the request to get the site settings 😄

But good to know you think the rest is sus 😂

Is there a need for the defaults? The retrieved settings are merged with the existing data - If any of the expected settings are not present or null, the existing data would be used?

🤔 I liked the typing of the response, but understand that that might not actually be what we get, was optimising for the happy path 😄 We could add a validation step? (i.e isSiteSettings()) or do you think its not worth the hassle over optional chaining?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think typing the response is nice - but we don't actually know what we get, so I was thinking that the lil wrapper gives us the type AND verifies it

RE: The host header - that's all good IMO, that's how we're differentiating between different sites

Defaults - hmm, I think we should have some defaults - because we don't want empty profiles, I don't think the object spread operator will ignore null values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeh ofc the null values wont get dropped 🤦‍♂️ Have gone with this approach and added in the defaults, nice one


// Update the database
const handle = 'index';
const db = ctx.get('db');

const current = await db.get<PersonData>(['handle', handle]);

await db.set(['handle', handle], {
...current,
icon: settings.site.icon,
name: settings.site.title,
summary: settings.site.description,
});
} catch (err) {
console.log(err);
}

// Return 200 OK
return new Response(JSON.stringify({}), {
headers: {
'Content-Type': 'application/activity+json',
},
status: 200,
});
}

export async function inboxHandler(
ctx: Context<{ Variables: HonoContextVariables }>,
next: Next,
Expand Down
107 changes: 107 additions & 0 deletions src/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
Image,
RequestContext,
generateCryptoKeyPair,
exportJwk,
importJwk,
} from '@fedify/fedify';
import { ContextData } from './app';

export type PersonData = {
id: string;
name: string;
summary: string;
preferredUsername: string;
icon: string;
inbox: string;
outbox: string;
following: string;
followers: string;
};

export async function getUserData(ctx: RequestContext<ContextData>, handle: string) {
const existing = await ctx.data.db.get<PersonData>(['handle', handle]);

if (existing) {
let icon = null;
try {
icon = new Image({ url: new URL(existing.icon) });
} catch (err) {
console.log('Could not create Image from Icon value', existing.icon);
console.log(err);
}
return {
id: new URL(existing.id),
name: existing.name,
summary: existing.summary,
preferredUsername: existing.preferredUsername,
icon,
inbox: new URL(existing.inbox),
outbox: new URL(existing.outbox),
following: new URL(existing.following),
followers: new URL(existing.followers),
publicKeys: (await ctx.getActorKeyPairs(handle)).map(
(key) => key.cryptographicKey,
),
};
}

const data = {
id: ctx.getActorUri(handle),
name: `Local Ghost site`,
summary: 'This is a summary',
preferredUsername: handle,
icon: new Image({ url: new URL('https://ghost.org/favicon.ico') }),
inbox: ctx.getInboxUri(handle),
outbox: ctx.getOutboxUri(handle),
following: ctx.getFollowingUri(handle),
followers: ctx.getFollowersUri(handle),
publicKeys: (await ctx.getActorKeyPairs(handle)).map(
(key) => key.cryptographicKey,
),
};

const dataToStore: PersonData = {
id: data.id.href,
name: data.name,
summary: data.summary,
preferredUsername: data.preferredUsername,
icon: 'https://ghost.org/favicon.ico',
inbox: data.inbox.href,
outbox: data.outbox.href,
following: data.following.href,
followers: data.followers.href,
};

await ctx.data.db.set(['handle', handle], data);

return data;
}

export async function getUserKeypair(ctx: ContextData, handle: string) {
const existing = await ctx.db.get<{ publicKey: any; privateKey: any }>([
'keypair',
handle,
]);

if (existing) {
return {
publicKey: await importJwk(existing.publicKey, 'public'),
privateKey: await importJwk(existing.privateKey, 'private'),
};
}

const keys = await generateCryptoKeyPair();

const data = {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
};

await ctx.db.set(['keypair', handle], {
publicKey: await exportJwk(data.publicKey),
privateKey: await exportJwk(data.privateKey),
});

return data;
}
36 changes: 8 additions & 28 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,11 @@ ky-universal@^0.11.0:
abort-controller "^3.0.0"
node-fetch "^3.2.10"

[email protected]:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.4.0.tgz#68b4a71eccfb4177199fe6ee2d5041b50bb41931"
integrity sha512-tPhhoGUiEiU/WXR4rt8klIoLdnTtyu+9jVKHd/wauEjYud32jyn63mzKWQweaQrHWxBQtYoVtdcEnYX1LosnFQ==

ky@^0.33.3:
version "0.33.3"
resolved "https://registry.yarnpkg.com/ky/-/ky-0.33.3.tgz#bf1ad322a3f2c3428c13cfa4b3af95e6c4a2f543"
Expand Down Expand Up @@ -1907,16 +1912,7 @@ sqlstring@^2.3.2:
resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c"
integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==

"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand All @@ -1934,14 +1930,7 @@ string-width@^5.0.1, string-width@^5.1.2:
emoji-regex "^9.2.2"
strip-ansi "^7.0.1"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand Down Expand Up @@ -2102,16 +2091,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand Down
Loading