From 5272b37e21526d6d95197d3f00ac85198a17f0cd Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Fri, 30 Aug 2024 13:29:43 +0100 Subject: [PATCH] wip --- src/_/Entity.ts | 3 ++ src/_/README.md | 60 +++++++++++++++++++++++ src/_/Repository.ts | 8 ++++ src/_/db.png | Bin 0 -> 43409 bytes src/_/entity/ActivityEntity.ts | 32 +++++++++++++ src/_/entity/ActorEntity.ts | 20 ++++++++ src/_/entity/ObjectEntity.ts | 20 ++++++++ src/_/entity/SiteEntity.ts | 20 ++++++++ src/_/repository/ActivityRepository.ts | 40 ++++++++++++++++ src/_/repository/ActorRepository.ts | 34 +++++++++++++ src/_/repository/InboxRepository.ts | 34 +++++++++++++ src/_/repository/ObjectRepository.ts | 34 +++++++++++++ src/_/repository/SiteRepository.ts | 18 +++++++ src/_/service/ActivityService.ts | 59 +++++++++++++++++++++++ src/_/service/ActorService.ts | 27 +++++++++++ src/_/service/InboxService.ts | 26 ++++++++++ src/_/service/ObjectService.ts | 28 +++++++++++ src/_/service/SiteService.ts | 18 +++++++ src/app.ts | 48 +++++++++++++++++-- src/db.ts | 48 +++++++++++++++++++ src/dispatchers.ts | 57 ++++++++++++++++------ src/handlers.ts | 63 ++++++++++++++----------- 22 files changed, 650 insertions(+), 47 deletions(-) create mode 100644 src/_/Entity.ts create mode 100644 src/_/README.md create mode 100644 src/_/Repository.ts create mode 100644 src/_/db.png create mode 100644 src/_/entity/ActivityEntity.ts create mode 100644 src/_/entity/ActorEntity.ts create mode 100644 src/_/entity/ObjectEntity.ts create mode 100644 src/_/entity/SiteEntity.ts create mode 100644 src/_/repository/ActivityRepository.ts create mode 100644 src/_/repository/ActorRepository.ts create mode 100644 src/_/repository/InboxRepository.ts create mode 100644 src/_/repository/ObjectRepository.ts create mode 100644 src/_/repository/SiteRepository.ts create mode 100644 src/_/service/ActivityService.ts create mode 100644 src/_/service/ActorService.ts create mode 100644 src/_/service/InboxService.ts create mode 100644 src/_/service/ObjectService.ts create mode 100644 src/_/service/SiteService.ts diff --git a/src/_/Entity.ts b/src/_/Entity.ts new file mode 100644 index 00000000..48400802 --- /dev/null +++ b/src/_/Entity.ts @@ -0,0 +1,3 @@ +export interface Entity { + serialize(): DTO +} diff --git a/src/_/README.md b/src/_/README.md new file mode 100644 index 00000000..ec64fb62 --- /dev/null +++ b/src/_/README.md @@ -0,0 +1,60 @@ +# ActivityPub Domain + +## Architecture + +### Service + +A service provides functionality for the application layer. Common operations include: + - Retrieving data from the repository layer and constructing entities + +### Entity + +An entity is a representation of a real-world object or concept. The service layer is responsible for +the creation of entities. Entities should define how they are serialized to a DTO. + +### Repository + +A repository provides functionality for interacting with a database. It should not be used directly by the application layer. Instead, it should be used by the service layer. DTOs are used to pass data between the repository and the service layer. + +### DTO + +A DTO is a data transfer object. It is used to pass data between the repository and the service layer. + +## Database + +DB Schema + +- `sites` - Information about each site that utilises the service +- `actors` - ActivityPub actors associated with objects and activities +- `objects` - ActivityPub objects associated with activities +- `activities` - ActivityPub activities + - Pivot table that references `actors` and `objects` +- `inbox` - Received activities for an actor + - Pivot table that references `actors` and `activities` +- ... + +## FAQs + +### Why is actvities a pivot table? + +ActivityPub activities largely follow the same structure: + +```json +{ + "id": "...", + "type": "...", + "actor": {}, + "object": {}, + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1" + ] +} +``` + +Rather than storing repated actor and object data, we can store a single actor and +object entity and reference them in the activity. This reduces the amount of data +that needs to be stored and allows for easier querying and indexing. This also makes +it easier to keeper actor data up-to-date, as we only need to update the data in one +place as well as allowing multiple activities to reference the same actor or object +without having to duplicate the data. diff --git a/src/_/Repository.ts b/src/_/Repository.ts new file mode 100644 index 00000000..1d65776c --- /dev/null +++ b/src/_/Repository.ts @@ -0,0 +1,8 @@ +import { Knex } from 'knex' + +export abstract class Repository { + constructor( + protected readonly db: Knex, + protected readonly tableName: string, + ) {} +} diff --git a/src/_/db.png b/src/_/db.png new file mode 100644 index 0000000000000000000000000000000000000000..712e3daf101b6c25865f2443c0e867696f398572 GIT binary patch literal 43409 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 z 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 literal 0 HcmV?d00001 diff --git a/src/_/entity/ActivityEntity.ts b/src/_/entity/ActivityEntity.ts new file mode 100644 index 00000000..3d236ad1 --- /dev/null +++ b/src/_/entity/ActivityEntity.ts @@ -0,0 +1,32 @@ +import { type Entity } from '../Entity' +import { ActorEntity } from './ActorEntity' +import { ObjectEntity } from './ObjectEntity' + +export enum ActivityType { + LIKE = 'Like' +} + +export type ActivityDTO = { + id: string + type: ActivityType + actor_id: string + object_id: string +} + +export class ActivityEntity implements Entity { + constructor( + readonly id: string, + readonly type: ActivityType, + readonly actor: ActorEntity, + readonly object: ObjectEntity, + ) {} + + serialize() { + return { + id: this.id, + type: this.type, + actor_id: this.actor.id, + object_id: this.object.id, + } + } +} diff --git a/src/_/entity/ActorEntity.ts b/src/_/entity/ActorEntity.ts new file mode 100644 index 00000000..e0fa7bde --- /dev/null +++ b/src/_/entity/ActorEntity.ts @@ -0,0 +1,20 @@ +import { type Entity } from '../Entity' + +export type ActorDTO = { + id: string + data: JSON +} + +export class ActorEntity implements Entity { + constructor( + readonly id: string, + readonly data: JSON, + ) {} + + serialize() { + return { + id: this.id, + data: this.data, + } + } +} diff --git a/src/_/entity/ObjectEntity.ts b/src/_/entity/ObjectEntity.ts new file mode 100644 index 00000000..68d2e4bf --- /dev/null +++ b/src/_/entity/ObjectEntity.ts @@ -0,0 +1,20 @@ +import { type Entity } from '../Entity' + +export type ObjectDTO = { + id: string + data: JSON +}; + +export class ObjectEntity implements Entity { + constructor( + readonly id: string, + readonly data: JSON, + ) {} + + serialize() { + return { + id: this.id, + data: this.data, + } + } +} diff --git a/src/_/entity/SiteEntity.ts b/src/_/entity/SiteEntity.ts new file mode 100644 index 00000000..39765608 --- /dev/null +++ b/src/_/entity/SiteEntity.ts @@ -0,0 +1,20 @@ +import { type Entity } from '../Entity' + +export type SiteDTO = { + id: number + hostname: string +} + +export class SiteEntity implements Entity { + constructor( + readonly id: number, + readonly hostname: string, + ) {} + + serialize() { + return { + id: this.id, + hostname: this.hostname, + } + } +} diff --git a/src/_/repository/ActivityRepository.ts b/src/_/repository/ActivityRepository.ts new file mode 100644 index 00000000..5a648edc --- /dev/null +++ b/src/_/repository/ActivityRepository.ts @@ -0,0 +1,40 @@ +import { Knex } from 'knex' + +import { Repository } from '../Repository' +import { type ActivityDTO } from '../entity/ActivityEntity' + +export class ActivityRepository extends Repository { + constructor( + db: Knex, + ) { + super(db, 'activities') + } + + async findById(id: string): Promise { + const result = await this.db(this.tableName).where('id', id).first() + + return result ?? null + } + + async findByIds(ids: string[]): Promise { + const results = await this.db(this.tableName).whereIn('id', ids) + + return results + } + + async create(data: ActivityDTO): Promise { + const result = await this.db(this.tableName).insert(data) + + if (result.length !== 1) { + throw new Error('Failed to create activity') + } + + const activity = await this.findById(data.id); + + if (!activity) { + throw new Error('Failed to create activity') + } + + return activity + } +} diff --git a/src/_/repository/ActorRepository.ts b/src/_/repository/ActorRepository.ts new file mode 100644 index 00000000..838fa332 --- /dev/null +++ b/src/_/repository/ActorRepository.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex' + +import { Repository } from '../Repository' +import { type ActorDTO } from '../entity/ActorEntity' + +export class ActorRepository extends Repository { + constructor( + db: Knex, + ) { + super(db, 'actors') + } + + async findById(id: string): Promise { + const result = await this.db(this.tableName).where('id', id).first() + + return result ?? null + } + + async create(data: ActorDTO): Promise { + const result = await this.db(this.tableName).insert(data) + + if (result.length !== 1) { + throw new Error('Failed to create actor') + } + + const object = await this.findById(data.id); + + if (!object) { + throw new Error('Failed to create actor') + } + + return object + } +} diff --git a/src/_/repository/InboxRepository.ts b/src/_/repository/InboxRepository.ts new file mode 100644 index 00000000..4845edd1 --- /dev/null +++ b/src/_/repository/InboxRepository.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex' + +import { Repository } from '../Repository' + +type InboxItemDTO = { + site_id: number + actor_id: string + activity_id: string +} + +export class InboxRepository extends Repository { + constructor( + db: Knex, + ) { + super(db, 'inbox') + } + + async findByActorId(actorId: string, siteId: number): Promise { + const results = await this.db(this.tableName) + .where('actor_id', actorId) + .where('site_id', siteId) + .select('*') + + return results + } + + async create(data: InboxItemDTO): Promise { + const result = await this.db(this.tableName).insert(data) + + if (result.length !== 1) { + throw new Error('Failed to create inbox item') + } + } +} diff --git a/src/_/repository/ObjectRepository.ts b/src/_/repository/ObjectRepository.ts new file mode 100644 index 00000000..01b0c82a --- /dev/null +++ b/src/_/repository/ObjectRepository.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex' + +import { Repository } from '../Repository' +import { type ObjectDTO } from '../entity/ObjectEntity' + +export class ObjectRepository extends Repository { + constructor( + db: Knex, + ) { + super(db, 'objects') + } + + async findById(id: string): Promise { + const result = await this.db(this.tableName).where('id', id).first() + + return result ?? null + } + + async create(data: ObjectDTO): Promise { + const result = await this.db(this.tableName).insert(data) + + if (result.length !== 1) { + throw new Error('Failed to create object') + } + + const object = await this.findById(data.id); + + if (!object) { + throw new Error('Failed to create object') + } + + return object + } +} diff --git a/src/_/repository/SiteRepository.ts b/src/_/repository/SiteRepository.ts new file mode 100644 index 00000000..9884e883 --- /dev/null +++ b/src/_/repository/SiteRepository.ts @@ -0,0 +1,18 @@ +import { Knex } from 'knex' + +import { Repository } from '../Repository' +import { type SiteDTO } from '../entity/SiteEntity' + +export class SiteRepository extends Repository { + constructor( + db: Knex, + ) { + super(db, 'sites') + } + + async findById(id: number): Promise { + const result = await this.db(this.tableName).where('id', id).first() + + return result ?? null + } +} diff --git a/src/_/service/ActivityService.ts b/src/_/service/ActivityService.ts new file mode 100644 index 00000000..00eb9cdc --- /dev/null +++ b/src/_/service/ActivityService.ts @@ -0,0 +1,59 @@ +import { ActivityEntity, type ActivityDTO } from '../entity/ActivityEntity' +import { ActivityRepository } from '../repository/ActivityRepository' +import { ActorService } from '../service/ActorService' +import { ObjectService } from '../service/ObjectService' + +export class ActivityService { + constructor( + private readonly activityRepository: ActivityRepository, + private readonly actorService: ActorService, + private readonly objectService: ObjectService, + ) {} + + async findById(id: string): Promise { + const activity = await this.activityRepository.findById(id) + + if (activity) { + return await this.#buildActivity(activity) + } + + return null + } + + async findByIds(ids: string[]): Promise { + const serializedActivities = await this.activityRepository.findByIds(ids) + const activities: ActivityEntity[] = [] + + for (const activity of serializedActivities) { + const builtActivity = await this.#buildActivity(activity) + + if (builtActivity) { + activities.push(builtActivity) + } + } + + return activities + } + + async create(data: ActivityDTO): Promise { + const serializedActivity = await this.activityRepository.create(data) + const activity = await this.#buildActivity(serializedActivity); + + if (!activity) { + throw new Error('Failed to create activity') + } + + return activity; + } + + async #buildActivity(activity: ActivityDTO) { + const object = await this.objectService.findById(activity.object_id) + const actor = await this.actorService.findById(activity.actor_id) + + if (object && actor) { + return new ActivityEntity(activity.id, activity.type, actor, object) + } + + return null + } +} diff --git a/src/_/service/ActorService.ts b/src/_/service/ActorService.ts new file mode 100644 index 00000000..45e277f8 --- /dev/null +++ b/src/_/service/ActorService.ts @@ -0,0 +1,27 @@ +import { ActorEntity, type ActorDTO } from '../entity/ActorEntity' +import { ActorRepository } from '../repository/ActorRepository' +export class ActorService { + constructor( + private readonly actorRepository: ActorRepository, + ) {} + + async findById(id: string): Promise { + const object = await this.actorRepository.findById(id) + + if (!object) { + return null + } + + return new ActorEntity(object.id, object.data) + } + + async create(data: ActorDTO): Promise { + const object = await this.actorRepository.create(data) + + if (!object) { + throw new Error('Failed to create object') + } + + return new ActorEntity(object.id, object.data) + } +} diff --git a/src/_/service/InboxService.ts b/src/_/service/InboxService.ts new file mode 100644 index 00000000..2bda7851 --- /dev/null +++ b/src/_/service/InboxService.ts @@ -0,0 +1,26 @@ +import { ActivityEntity } from '../entity/ActivityEntity' +import { ActorEntity } from '../entity/ActorEntity' +import { ActivityService } from './ActivityService' +import { InboxRepository } from '../repository/InboxRepository' +import { SiteEntity } from '../entity/SiteEntity' +export class InboxService { + constructor( + private readonly activityService: ActivityService, + private readonly inboxRepository: InboxRepository, + ) {} + + async getInboxForActor(actor: ActorEntity, site: SiteEntity): Promise { + const actorActivities = await this.inboxRepository.findByActorId(actor.id, site.id); + const activities = await this.activityService.findByIds(actorActivities.map(activity => activity.activity_id)); + + return activities; + } + + async addActivityForActor(site: SiteEntity, actor: ActorEntity, activity: ActivityEntity) { + await this.inboxRepository.create({ + site_id: site.id, + actor_id: actor.id, + activity_id: activity.id, + }); + } +} diff --git a/src/_/service/ObjectService.ts b/src/_/service/ObjectService.ts new file mode 100644 index 00000000..0308a598 --- /dev/null +++ b/src/_/service/ObjectService.ts @@ -0,0 +1,28 @@ +import { ObjectEntity, type ObjectDTO } from '../entity/ObjectEntity' +import { ObjectRepository } from '../repository/ObjectRepository' + +export class ObjectService { + constructor( + private readonly objectRepository: ObjectRepository, + ) {} + + async findById(id: string): Promise { + const object = await this.objectRepository.findById(id) + + if (!object) { + return null + } + + return new ObjectEntity(object.id, object.data) + } + + async create(data: ObjectDTO): Promise { + const object = await this.objectRepository.create(data) + + if (!object) { + throw new Error('Failed to create object') + } + + return new ObjectEntity(object.id, object.data) + } +} diff --git a/src/_/service/SiteService.ts b/src/_/service/SiteService.ts new file mode 100644 index 00000000..ba11cda3 --- /dev/null +++ b/src/_/service/SiteService.ts @@ -0,0 +1,18 @@ +import { SiteEntity } from '../entity/SiteEntity' +import { SiteRepository } from '../repository/SiteRepository' + +export class SiteService { + constructor( + private readonly siteRepository: SiteRepository, + ) {} + + async findById(id: number): Promise { + const site = await this.siteRepository.findById(id) + + if (!site) { + return null + } + + return new SiteEntity(site.id, site.hostname) + } +} diff --git a/src/app.ts b/src/app.ts index dbb99018..8b3f9def 100644 --- a/src/app.ts +++ b/src/app.ts @@ -63,9 +63,37 @@ await configure({ loggers: [{ category: 'fedify', sinks: ['console'], level: 'debug' }], }); +/** Services */ + +import { ActivityRepository } from './_/repository/ActivityRepository'; +import { ActivityService } from './_/service/ActivityService'; +import { ActorRepository } from './_/repository/ActorRepository'; +import { ActorService } from './_/service/ActorService'; +import { InboxRepository } from './_/repository/InboxRepository'; +import { InboxService } from './_/service/InboxService'; +import { ObjectRepository } from './_/repository/ObjectRepository'; +import { ObjectService } from './_/service/ObjectService'; +import { SiteRepository } from './_/repository/SiteRepository'; +import { SiteService } from './_/service/SiteService'; + +const actorService = new ActorService(new ActorRepository(client)); +const objectService = new ObjectService(new ObjectRepository(client)); +const siteService = new SiteService(new SiteRepository(client)); +const activityService = new ActivityService(new ActivityRepository(client), actorService, objectService); +const inboxService = new InboxService(activityService, new InboxRepository(client)); + +export const db = await KnexKvStore.create(client, 'key_value'); + +/** Fedify */ + export type ContextData = { db: KvStore; globaldb: KvStore; + activityService: ActivityService; + actorService: ActorService; + inboxService: InboxService; + objectService: ObjectService; + siteService: SiteService; }; const fedifyKv = await KnexKvStore.create(client, 'key_value'); @@ -75,10 +103,6 @@ export const fedify = createFederation({ skipSignatureVerification: process.env.SKIP_SIGNATURE_VERIFICATION === 'true' && process.env.NODE_ENV === 'testing', }); -export const db = await KnexKvStore.create(client, 'key_value'); - -/** Fedify */ - /** * Fedify does not pass the correct context object when running outside of the request context * for example in the context of the Inbox Queue - so we need to wrap handlers with this. @@ -178,6 +202,11 @@ fedify.setObjectDispatcher( export type HonoContextVariables = { db: KvStore; globaldb: KvStore; + activityService: ActivityService; + actorService: ActorService; + inboxService: InboxService; + objectService: ObjectService; + siteService: SiteService; }; const app = new Hono<{ Variables: HonoContextVariables }>(); @@ -241,7 +270,11 @@ app.use(async (ctx, next) => { ctx.set('db', scopedDb); ctx.set('globaldb', db); - + ctx.set('activityService', activityService); + ctx.set('actorService', actorService); + ctx.set('inboxService', inboxService); + ctx.set('objectService', objectService); + ctx.set('siteService', siteService); await next(); }); @@ -267,6 +300,11 @@ app.use( return { db: ctx.get('db'), globaldb: ctx.get('globaldb'), + activityService: ctx.get('activityService'), + actorService: ctx.get('actorService'), + inboxService: ctx.get('inboxService'), + objectService: ctx.get('objectService'), + siteService: ctx.get('siteService'), }; }, ), diff --git a/src/db.ts b/src/db.ts index 471d58fe..7de7ced9 100644 --- a/src/db.ts +++ b/src/db.ts @@ -17,3 +17,51 @@ await client.schema.createTableIfNotExists('key_value', function (table) { table.json('value').notNullable(); table.datetime('expires').nullable(); }); + +const TABLE_SITES = 'sites'; +await client.schema.createTableIfNotExists(TABLE_SITES, function (table) { + table.increments('id').primary(); + table.string('hostname', 2048); +}); +await client.table(TABLE_SITES).truncate(); +await client.table(TABLE_SITES).insert({ + id: 1, + hostname: 'localhost', +}); + +const TABLE_ACTORS = 'actors'; +await client.schema.createTableIfNotExists(TABLE_ACTORS, function (table) { + table.string('id').primary(); + table.json('data').notNullable(); +}); +await client.table(TABLE_ACTORS).truncate(); +await client.table(TABLE_ACTORS).insert({ + id: 'https://localhost/users/1', + data: { + id: 'https://localhost/users/1' + }, +}); + +const TABLE_OBJECTS = 'objects'; +await client.schema.createTableIfNotExists(TABLE_OBJECTS, function (table) { + table.string('id').primary(); + table.json('data').notNullable(); +}); +await client.table(TABLE_OBJECTS).truncate(); + +const TABLE_ACTIVITIES = 'activities'; +await client.schema.createTableIfNotExists(TABLE_ACTIVITIES, function (table) { + table.string('id').primary(); + table.enum('type', ['Like']); + table.string('actor_id'); + table.string('object_id'); +}); +await client.table(TABLE_ACTIVITIES).truncate(); + +const TABLE_INBOX = 'inbox'; +await client.schema.createTableIfNotExists(TABLE_INBOX, function (table) { + table.integer('site_id'); + table.string('actor_id'); + table.string('activity_id'); +}); +await client.table(TABLE_INBOX).truncate(); diff --git a/src/dispatchers.ts b/src/dispatchers.ts index 92669011..5a155305 100644 --- a/src/dispatchers.ts +++ b/src/dispatchers.ts @@ -22,6 +22,7 @@ import { addToList } from './kv-helpers'; import { ContextData } from './app'; import { ACTOR_DEFAULT_HANDLE } from './constants'; import { getUserData, getUserKeypair } from './user'; +import { ActivityType } from '_/entity/ActivityEntity'; export async function actorDispatcher( ctx: RequestContext, @@ -236,7 +237,7 @@ export async function handleLike( ) { console.log('Handling Like'); - // Validate like + // Validate activity if (!like.id) { console.log('Invalid Like - no id'); return; @@ -255,18 +256,29 @@ export async function handleLike( return; } - // Lookup liked object - If not found in globalDb, perform network lookup + // Persist sender details + let storedSender = await ctx.data.actorService.findById(sender.id.href); + if (!storedSender) { + await ctx.data.actorService.create({ + id: sender.id.href, + data: await sender.toJsonLd() as JSON + }); + } else { + // Update sender? + } + + // Lookup associated object - If not found locally, perform network lookup let object = null; - let existing = await ctx.data.globaldb.get([like.objectId.href]) ?? null; + let storedObject = await ctx.data.objectService.findById(like.objectId.href); - if (!existing) { - console.log('Object not found in globalDb, performing network lookup'); + if (!storedObject) { + console.log('Object not found locally, performing network lookup'); object = await like.getObject(); } // Validate object - if (!existing && !object) { + if (!storedObject && !object) { console.log('Invalid Like - could not find object'); return; } @@ -276,21 +288,36 @@ export async function handleLike( return; } - // Persist like - const likeJson = await like.toJsonLd(); - ctx.data.globaldb.set([like.id.href], likeJson); - // Persist object if not already persisted - if (!existing && object && object.id) { - console.log('Storing object in globalDb'); + if (!storedObject && object && object.id) { + console.log('Storing object in db'); - const objectJson = await object.toJsonLd(); + const objectJSON = await object.toJsonLd() as JSON; - ctx.data.globaldb.set([object.id.href], objectJson); + storedObject = await ctx.data.objectService.create({ + id: object.id.href, + data: objectJSON + }); } + // Persist activity + const actor = await ctx.data.actorService.findById('https://localhost/users/1'); + if (!actor) { + throw new Error('actor not found'); + } + const site = await ctx.data.siteService.findById(1); + if (!site) { + throw new Error('site not found'); + } + const activity = await ctx.data.activityService.create({ + id: like.id.href, + type: ActivityType.LIKE, + actor_id: actor.id, + object_id: storedObject.id, + }); + // Add to inbox - await addToList(ctx.data.db, ['inbox'], like.id.href); + await ctx.data.inboxService.addActivityForActor(site, actor, activity); } export async function inboxErrorHandler( diff --git a/src/handlers.ts b/src/handlers.ts index 6e0ab92c..01dfbdc5 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -79,6 +79,11 @@ export async function followAction( const apCtx = fedify.createContext(ctx.req.raw as Request, { db: ctx.get('db'), globaldb: ctx.get('globaldb'), + activityService: ctx.get('activityService'), + actorService: ctx.get('actorService'), + inboxService: ctx.get('inboxService'), + objectService: ctx.get('objectService'), + siteService: ctx.get('siteService'), }); const actor = await apCtx.getActor(ACTOR_DEFAULT_HANDLE); // TODO This should be the actor making the request const followId = apCtx.getObjectUri(Follow, { @@ -118,6 +123,11 @@ export async function postPublishedWebhook( const apCtx = fedify.createContext(ctx.req.raw as Request, { db: ctx.get('db'), globaldb: ctx.get('globaldb'), + activityService: ctx.get('activityService'), + actorService: ctx.get('actorService'), + inboxService: ctx.get('inboxService'), + objectService: ctx.get('objectService'), + siteService: ctx.get('siteService'), }); const { article, preview } = await postToArticle( apCtx, @@ -207,6 +217,11 @@ export async function siteChangedWebhook( const apCtx = fedify.createContext(ctx.req.raw as Request, { db, globaldb: ctx.get('globaldb'), + activityService: ctx.get('activityService'), + actorService: ctx.get('actorService'), + inboxService: ctx.get('inboxService'), + objectService: ctx.get('objectService'), + siteService: ctx.get('siteService'), }); const actor = await apCtx.getActor(handle); @@ -241,35 +256,29 @@ export async function inboxHandler( ctx: Context<{ Variables: HonoContextVariables }>, next: Next, ) { - const results = (await ctx.get('db').get(['inbox'])) || []; - let items: unknown[] = []; - for (const result of results) { - try { - const db = ctx.get('globaldb'); - const thing = await db.get([result]); - - // If the object is a string, it's probably a URI, so we should - // look it up the db. If it's not in the db, we should just leave - // it as is - if (thing && typeof thing.object === 'string') { - thing.object = await db.get([thing.object]) ?? thing.object; - } + const actor = await ctx.get('actorService').findById('https://localhost/users/1') + if (!actor) { + throw new Error('actor not found'); + } + const site = await ctx.get('siteService').findById(1); + if (!site) { + throw new Error('site not found'); + } - // Sanitize HTML content - if (thing?.object && typeof thing.object !== 'string') { - thing.object.content = sanitizeHtml(thing.object.content, { - allowedTags: ['a', 'p', 'img', 'br', 'strong', 'em', 'span'], - allowedAttributes: { - a: ['href'], - img: ['src'], - } - }); - } + const results = await ctx.get('inboxService').getInboxForActor(actor, site); + const items = []; - items.push(thing); - } catch (err) { - console.log(err); - } + for (const result of results) { + items.push({ + id: result.id, + type: result.type, + actor: result.actor.data, + object: result.object.data, + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1" + ] + }); } return new Response( JSON.stringify({