From 9b2cb8529d45e61d05ab43905a14e8c4a9933d38 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 | 77 +++++++++++++++++++++++++ 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 | 38 ++++++++++++ src/_/repository/ActorRepository.ts | 32 ++++++++++ src/_/repository/InboxRepository.ts | 32 ++++++++++ src/_/repository/ObjectRepository.ts | 32 ++++++++++ src/_/repository/SiteRepository.ts | 40 +++++++++++++ src/_/service/ActivityService.ts | 59 +++++++++++++++++++ src/_/service/ActorService.ts | 27 +++++++++ src/_/service/InboxService.ts | 26 +++++++++ src/_/service/ObjectService.ts | 28 +++++++++ src/_/service/SiteService.ts | 28 +++++++++ src/app.ts | 53 +++++++++++++++-- src/db.ts | 44 ++++++++++++++ src/dispatchers.ts | 54 ++++++++++++----- src/handlers.ts | 61 +++++++++++--------- 22 files changed, 687 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..b54c524f --- /dev/null +++ b/src/_/Entity.ts @@ -0,0 +1,3 @@ +export interface Entity { + serialize(): TDTO +} diff --git a/src/_/README.md b/src/_/README.md new file mode 100644 index 00000000..487cb970 --- /dev/null +++ b/src/_/README.md @@ -0,0 +1,77 @@ +# ActivityPub Domain + +## Architecture + +### Service + +Provides functionality for the application layer. Common operations include: + - Retrieving data from the repository layer and constructing entities + +### Entity + +Represents a real-world object or concept. The service layer is responsible for +the creation of entities. Entities currently define how they are serialized to a +DTO in lieu of a data-mapper layer. + +### Repository + +Provides functionality for interacting with a database. Typically used by the +service layer. DTOs are used to pass data between the repository and the service +layer. + +### DTO + +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` +- ... + +## Conventions + +- If an entity references another entity, when it is created, the referenced entity +should also be created. This reference is normally indicated via a `_id` field +in the entity's DTO. + +## Notes, Thoughts, Questions + +### How does data get scoped? + +When a request is received, the hostname is extracted from the request and used to +determine the site that the request is scoped to. The only data that requires scoping +is actor data within the context of a site (i.e the same actor can be present on +multiple sites). Site specific actor data includes: +- Inbox + +### 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 keep 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..e372d220 --- /dev/null +++ b/src/_/repository/ActivityRepository.ts @@ -0,0 +1,38 @@ +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..4e0cb93c --- /dev/null +++ b/src/_/repository/ActorRepository.ts @@ -0,0 +1,32 @@ +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 actor = await this.findById(data.id); + + if (!actor) { + throw new Error('Failed to create actor') + } + + return actor + } +} diff --git a/src/_/repository/InboxRepository.ts b/src/_/repository/InboxRepository.ts new file mode 100644 index 00000000..156a386a --- /dev/null +++ b/src/_/repository/InboxRepository.ts @@ -0,0 +1,32 @@ +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..97c58274 --- /dev/null +++ b/src/_/repository/ObjectRepository.ts @@ -0,0 +1,32 @@ +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..410b08a3 --- /dev/null +++ b/src/_/repository/SiteRepository.ts @@ -0,0 +1,40 @@ +import { Knex } from 'knex' + +import { Repository } from '../Repository' +import { type SiteDTO } from '../entity/SiteEntity' + +type CreateSiteDTO = Omit + +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 + } + + async findByHostname(hostname: string): Promise { + const result = await this.db(this.tableName).where('hostname', hostname).first() + + return result ?? null + } + + async create(data: CreateSiteDTO): Promise { + const result = await this.db(this.tableName).insert(data) + + if (result.length !== 1) { + throw new Error('Failed to create site') + } + + const object = await this.findById(result[0]); + + if (!object) { + throw new Error('Failed to create site') + } + + return object + } +} 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..8081ae5b --- /dev/null +++ b/src/_/service/SiteService.ts @@ -0,0 +1,28 @@ +import { SiteEntity } from '../entity/SiteEntity' +import { SiteRepository } from '../repository/SiteRepository' + +export class SiteService { + constructor( + private readonly siteRepository: SiteRepository, + ) {} + + async findByHostname(host: string): Promise { + const site = await this.siteRepository.findByHostname(host) + + if (!site) { + return null + } + + return new SiteEntity(site.id, site.hostname) + } + + async create(hostname: string): Promise { + const site = await this.siteRepository.create({ hostname }) + + if (!site) { + throw new Error('Failed to create site') + } + + return new SiteEntity(site.id, site.hostname) + } +} diff --git a/src/app.ts b/src/app.ts index dbb99018..12d92bb1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -63,9 +63,38 @@ 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 { SiteEntity } from './_/entity/SiteEntity'; +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; + site: SiteEntity; }; const fedifyKv = await KnexKvStore.create(client, 'key_value'); @@ -75,10 +104,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 +203,11 @@ fedify.setObjectDispatcher( export type HonoContextVariables = { db: KvStore; globaldb: KvStore; + activityService: ActivityService; + actorService: ActorService; + inboxService: InboxService; + objectService: ObjectService; + site: SiteEntity; }; const app = new Hono<{ Variables: HonoContextVariables }>(); @@ -242,6 +272,16 @@ app.use(async (ctx, next) => { ctx.set('db', scopedDb); ctx.set('globaldb', db); + let site = await siteService.findByHostname(host); + if (!site) { + site = await siteService.create(host); + } + ctx.set('activityService', activityService); + ctx.set('actorService', actorService); + ctx.set('inboxService', inboxService); + ctx.set('objectService', objectService); + ctx.set('site', site); + await next(); }); @@ -267,6 +307,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'), + site: ctx.get('site'), }; }, ), diff --git a/src/db.ts b/src/db.ts index 471d58fe..9f27deac 100644 --- a/src/db.ts +++ b/src/db.ts @@ -17,3 +17,47 @@ 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(); + +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..f5b78eda 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,33 @@ 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 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); + const site = await ctx.data.site + await ctx.data.inboxService.addActivityForActor(site, actor, activity); } export async function inboxErrorHandler( diff --git a/src/handlers.ts b/src/handlers.ts index 6e0ab92c..40d4114f 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'), + site: ctx.get('site'), }); 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'), + site: ctx.get('site'), }); 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'), + site: ctx.get('site'), }); const actor = await apCtx.getActor(handle); @@ -241,35 +256,25 @@ 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; - } - - // 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 actor = await ctx.get('actorService').findById('https://localhost/users/1') + if (!actor) { + throw new Error('actor not found'); + } + const site = await ctx.get('site'); + 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({