diff --git a/src/database/index.ts b/src/database/index.ts index cf24064..07053fd 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -16,16 +16,6 @@ export type DbActor = { private_ed: JsonWebKey, } -export type DbActorService = DbActor - -export type DbActorPlayer = DbActor & { - steam_id: bigint, -} - -export type DbActorGuild = DbActor & { - guild_id: number, -} - export class Database { sql: Postgres.Sql @@ -70,7 +60,7 @@ export class Database { } async reinitializeDev() { - l.warn`Re-initializing database from scratch for development!!!` + l.fatal`!!! Re-initializing database from scratch for development !!!` l.info`01/XX Dropping public schema from database...` await this.#doQueryFile("src/database/init/01-drop-schema.sql") @@ -78,22 +68,10 @@ export class Database { l.info`02/XX Recreating public schema...` await this.#doQueryFile("src/database/init/02-create-schema.sql") - l.info`03/XX Creating applied migrations table...` - await this.#doQueryFile("src/database/init/03-create-migration_applied.sql") + l.info`03/XX Creating actor table...` + await this.#doQueryFile("src/database/init/03-create-actor.sql") - l.info`04/XX Creating actor table...` - await this.#doQueryFile("src/database/init/04-create-actor.sql") - - l.info`05/XX Creating service table...` - await this.#doQueryFile("src/database/init/05-create-actor_service.sql") - - l.info`06/XX Creating player table...` - await this.#doQueryFile("src/database/init/06-create-actor_player.sql") - - l.info`07/XX Creating guild table...` - await this.#doQueryFile("src/database/init/07-create-actor_guild.sql") - - l.info`08/XX Creating service actor...` + l.info`04/XX Creating service actor...` l.debug`Creating RSA key pair...` const {publicKey: publicRsa, privateKey: privateRsa} = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5") l.debug`Creating elliptic curve key pair...` @@ -104,8 +82,8 @@ export class Database { const privateRsaJwk = await exportJwk(privateRsa) const privateEdJwk = await exportJwk(privateEd) l.debug`Running SQL query...` - await this.#doQueryFile( - "src/database/init/08-insert-actor_service.sql", + await this.#doQueryFile( + "src/database/init/04-insert-actor-app.sql", [ publicRsaJwk, publicEdJwk, @@ -114,24 +92,24 @@ export class Database { ] ) - l.info`09/XX Creating follow table...` - await this.#doQueryFile("src/database/init/09-create-follow.sql") + l.info`05/XX Creating follow table...` + await this.#doQueryFile("src/database/init/05-create-follow.sql") l.info("Done!") } - async getActorService(): Promise { - l.info`Getting service actor entity...` + async getActorApp(): Promise { + l.info`Getting app actor entity...` - const results = await this.#doQueryFile("src/database/query/get-actor_service.sql") + const results = await this.#doQueryFile("src/database/query/get-actor_app.sql") if(results.count == 0) { - l.error`No service actor entity exists.` - throw new Error("No service actor entity exists.") + l.error`No app actor entity exists.` + throw new Error("No app actor entity exists.") } else if(results.count > 1) { - l.error`Multiple service actor entities exist.` - throw new Error("Multiple service actor entities exist.") + l.error`Multiple app actor entities exist.` + throw new Error("Multiple app actor entities exist.") } l.debug`Attempting to get only result...` @@ -141,10 +119,10 @@ export class Database { return result } - async getActorPlayer(steamId: number): Promise { + async getActorPlayer(steamId: number): Promise { l.info`Getting player actor entity with id: ${steamId}` - const results = await this.#doQueryFile("src/database/query/get-actor_player.sql", [steamId]) + const results = await this.#doQueryFile("src/database/query/get-actor_player.sql", [steamId]) if(results.count == 0) { l.debug`No such player actor entity exists.` @@ -162,10 +140,10 @@ export class Database { return result } - async getActorGuild(guildId: number): Promise { + async getActorGuild(guildId: number): Promise { l.info`Getting guild actor entity with id: ${guildId}` - const results = await this.#doQueryFile("src/database/query/get-actor_guild.sql", [guildId]) + const results = await this.#doQueryFile("src/database/query/get-actor_guild.sql", [guildId]) if(results.count == 0) { l.debug`No such guild actor entity exists.` @@ -188,4 +166,10 @@ export class Database { await this.#doQueryFile("src/database/query/put-follow.sql", [follower, followed]) } + + async deleteFollow(follower: string, followed: string): Promise { + l.info`Deleting follow: from ${follower} to ${followed}` + + await this.#doQueryFile("src/database/query/delete-follow.sql", [follower, followed]) + } } diff --git a/src/database/init/03-create-actor.sql b/src/database/init/03-create-actor.sql new file mode 100644 index 0000000..1787ec0 --- /dev/null +++ b/src/database/init/03-create-actor.sql @@ -0,0 +1,8 @@ +CREATE TABLE actor ( + handle VARCHAR PRIMARY KEY, + + public_rsa JSON, + public_ed JSON, + private_rsa JSON, + private_ed JSON +); diff --git a/src/database/init/03-create-migration_applied.sql b/src/database/init/03-create-migration_applied.sql deleted file mode 100644 index 7aac154..0000000 --- a/src/database/init/03-create-migration_applied.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TABLE migration_applied ( - code CHAR(4) PRIMARY KEY -); diff --git a/src/database/init/04-create-actor.sql b/src/database/init/04-create-actor.sql deleted file mode 100644 index a313e49..0000000 --- a/src/database/init/04-create-actor.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE TABLE actor ( - handle VARCHAR PRIMARY KEY -); diff --git a/src/database/init/08-insert-actor_service.sql b/src/database/init/04-insert-actor-app.sql similarity index 69% rename from src/database/init/08-insert-actor_service.sql rename to src/database/init/04-insert-actor-app.sql index e44ae36..5377df2 100644 --- a/src/database/init/08-insert-actor_service.sql +++ b/src/database/init/04-insert-actor-app.sql @@ -1,11 +1,11 @@ -INSERT INTO actor_service ( +INSERT INTO actor ( handle, public_rsa, public_ed, private_rsa, private_ed ) VALUES ( - 'service', + 'app', $1, $2, $3, diff --git a/src/database/init/05-create-actor_service.sql b/src/database/init/05-create-actor_service.sql deleted file mode 100644 index e8c5c5e..0000000 --- a/src/database/init/05-create-actor_service.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE actor_service ( - public_rsa JSON NOT NULL, - public_ed JSON NOT NULL, - private_rsa JSON NOT NULL, - private_ed JSON NOT NULL -) INHERITS ( - actor -); diff --git a/src/database/init/05-create-follow.sql b/src/database/init/05-create-follow.sql new file mode 100644 index 0000000..b465823 --- /dev/null +++ b/src/database/init/05-create-follow.sql @@ -0,0 +1,9 @@ +CREATE TABLE follow ( + follower VARCHAR NOT NULL, + followed VARCHAR REFERENCES actor (handle) NOT NULL, + + PRIMARY KEY ( + follower, + followed + ) +); diff --git a/src/database/init/06-create-actor_player.sql b/src/database/init/06-create-actor_player.sql deleted file mode 100644 index 8ff5637..0000000 --- a/src/database/init/06-create-actor_player.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE actor_player ( - steam_id BIGINT UNIQUE NOT NULL -) INHERITS ( - actor -); diff --git a/src/database/init/07-create-actor_guild.sql b/src/database/init/07-create-actor_guild.sql deleted file mode 100644 index 62910d6..0000000 --- a/src/database/init/07-create-actor_guild.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE actor_guild ( - guild_id INT UNIQUE NOT NULL -) INHERITS ( - actor -); diff --git a/src/database/init/09-create-follow.sql b/src/database/init/09-create-follow.sql deleted file mode 100644 index 4864644..0000000 --- a/src/database/init/09-create-follow.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE follow ( - follower VARCHAR REFERENCES (actor.handle) NOT NULL, - followed VARCHAR REFERENCES (actor.handle) NOT NULL, - - PRIMARY KEY ( - follower, - followed - ) -); diff --git a/src/database/query/delete-follow.sql b/src/database/query/delete-follow.sql index 6637479..f0f7e9d 100644 --- a/src/database/query/delete-follow.sql +++ b/src/database/query/delete-follow.sql @@ -3,4 +3,4 @@ DELETE FROM WHERE follow.follower = $1 AND - follow.followed = $2; \ No newline at end of file + follow.followed = $2; diff --git a/src/database/query/get-actor_app.sql b/src/database/query/get-actor_app.sql new file mode 100644 index 0000000..21ea7dd --- /dev/null +++ b/src/database/query/get-actor_app.sql @@ -0,0 +1,10 @@ +SELECT + actor.handle, + actor.public_rsa, + actor.public_ed, + actor.private_rsa, + actor.private_ed +FROM + actor +WHERE + actor.handle = 'app'; diff --git a/src/database/query/get-actor_guild.sql b/src/database/query/get-actor_guild.sql index 8c85971..af37678 100644 --- a/src/database/query/get-actor_guild.sql +++ b/src/database/query/get-actor_guild.sql @@ -1,12 +1,10 @@ SELECT - actor_guild.handle, - actor_guild.guild_id, - actor_service.public_rsa, - actor_service.public_ed, - actor_service.private_rsa, - actor_service.private_ed + actor.handle, + actor.public_rsa, + actor.public_ed, + actor.private_rsa, + actor.private_ed FROM - actor_guild, - actor_service + actor WHERE - actor_guild.steam_id = $1; + actor.handle = 'g' || $1; diff --git a/src/database/query/get-actor_player.sql b/src/database/query/get-actor_player.sql index 6a65da9..43b845b 100644 --- a/src/database/query/get-actor_player.sql +++ b/src/database/query/get-actor_player.sql @@ -1,12 +1,10 @@ SELECT - actor_player.handle, - actor_player.steam_id, - actor_service.public_rsa, - actor_service.public_ed, - actor_service.private_rsa, - actor_service.private_ed + actor.handle, + actor.public_rsa, + actor.public_ed, + actor.private_rsa, + actor.private_ed FROM - actor_player, - actor_service + actor WHERE - actor_player.steam_id = $1; + actor.handle = 'u' || $1; diff --git a/src/database/query/get-actor_service.sql b/src/database/query/get-actor_service.sql deleted file mode 100644 index c1997bf..0000000 --- a/src/database/query/get-actor_service.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - actor_service.handle, - actor_service.public_rsa, - actor_service.public_ed, - actor_service.private_rsa, - actor_service.private_ed -FROM - actor_service; diff --git a/src/dv/actor.ts b/src/dv/actor.ts new file mode 100644 index 0000000..0944175 --- /dev/null +++ b/src/dv/actor.ts @@ -0,0 +1,196 @@ +import { Actor, Application, Context, Endpoints, Image, Organization, Person, PropertyValue } from "@fedify/fedify" +import { ContextData } from "./index.ts" +import { getLogger } from "@logtape/logtape" +import { escapeHtml } from "@@x/escape" +import { DvComponent } from "./component.ts" + + +const l = getLogger(["dotino-veloce", "dv", "actor", "common"]) +const lApp = getLogger(["dotino-veloce", "dv", "actor", "app"]) +const lPlayer = getLogger(["dotino-veloce", "dv", "actor", "player"]) +const lGuild = getLogger(["dotino-veloce", "dv", "actor", "guild"]) + + +export class ActorComponent extends DvComponent { + static appMatcher = /app/ + static playerMatcher = /u([0-9]+)/ + static guildMatcher = /g([0-9]+)/ + + async getActor(ctx: Context, handle: string): Promise { + l.info`Getting actor: ${handle}` + + let actor = null + + actor ??= await this.getAppActor(ctx, handle) + actor ??= await this.getPlayerActor(ctx, handle) + actor ??= await this.getGuildActor(ctx, handle) + + l.debug`Obtained actor: ${handle}` + + return actor + } + + async getCommonActorProperties(ctx: Context, handle: string): Promise> { + l.info`Generating common actor properties for ${handle}...` + const properties = { + preferredUsername: handle, + id: ctx.getActorUri(handle), + + // This is required for compatibility with a whole lot of servers + publicKeys: (await ctx.getActorKeyPairs(handle)).map(keyPair => keyPair.cryptographicKey), + assertionMethods: (await ctx.getActorKeyPairs(handle)).map(keyPair => keyPair.multikey), + + // Akkoma requires inboxes to be setup to display profiles + // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 + inbox: ctx.getInboxUri(handle), + endpoints: new Endpoints({ + sharedInbox: ctx.getInboxUri(), + }), + } + l.debug`Generated common actor properties for ${handle}: ${properties}` + return properties + } + + async getAppActor(ctx: Context, handle: string): Promise { + lApp.debug`Checking if handle ${handle} matches regex for the app account...` + const match = handle.match(ActorComponent.appMatcher) + if(!match) { + lApp.debug`No match for app account regex, returning null.` + return null + } + lApp.info`Attempting to generate ActivityPub actor for app...` + + lApp.debug`Creating ActivityPub actor for app...` + const actor = new Application({ + ...(await this.getCommonActorProperties(ctx, handle)), + + name: "[TEST] Dotino Veloce", + summary: "App account of a Dotino Veloce instance.", + attachments: [ + new PropertyValue({ + name: "Source code", + value: `https://forge.steffo.eu/steffo/dotino-veloce`, + }), + ] + }) + + lApp.debug`Generated ActivityPub actor for service: ${actor}` + return actor + } + + async getPlayerActor(ctx: Context, handle: string): Promise { + lPlayer.debug`Checking if handle ${handle} matches regex for player accounts...` + const match = handle.match(ActorComponent.playerMatcher) + if(!match) { + lPlayer.debug`No match for player account regex, returning null.` + return null + } + lApp.info`Attempting to generate ActivityPub actor for player...` + + lPlayer.debug`Getting handle ${handle}'s SteamID...` + const steamId = Number.parseInt(match[1] as any) + lPlayer.debug`Handle ${handle}'s SteamID seems to be: ${steamId}` + + lPlayer.debug`Making sure the SteamID parsing didn't explode...` + if(!Number.isFinite(steamId)) { + lPlayer.error`SteamID parsing for ${handle} exploded with ${steamId}, returning null.` + return null + } + + lPlayer.debug`Getting player data for ${steamId}...` + const player = await this.dv.stratz.doQueryPlayer(steamId) + if(player === null) { + lPlayer.debug`No Steam account was found with ID: ${steamId}` + return null + } + + lPlayer.debug`Creating ActivityPub actor for player ${steamId}...` + const actor = new Person({ + ...(await this.getCommonActorProperties(ctx, handle)), + + name: `[TEST] ${escapeHtml(player.name)}`, + icon: new Image({ + url: new URL(player.avatar), + mediaType: "image/jpeg" + }), + attachments: [ + new PropertyValue({ + name: "Steam", + value: `https://steamcommunity.com/profiles/[U:1:${player.id}]`, + }), + new PropertyValue({ + name: "STRATZ", + value: `https://stratz.com/players/${player.id}`, + }), + new PropertyValue({ + name: "OpenDota", + value: `https://www.opendota.com/players/${player.id}`, + }), + new PropertyValue({ + name: "DOTABUFF", + value: `https://www.dotabuff.com/players/${player.id}`, + }), + ], + }) + lPlayer.debug`Generated ActivityPub actor for player ${steamId}: ${actor}` + + return actor + } + + async getGuildActor(ctx: Context, handle: string): Promise { + lGuild.debug`Checking if handle ${handle} matches regex for guild accounts...` + const match = handle.match(ActorComponent.guildMatcher) + if(!match) { + lGuild.debug`No match for guild account regex, returning null.` + return null + } + lApp.info`Attempting to generate ActivityPub actor for guild...` + + lGuild.debug`Getting handle ${handle}'s Guild ID...` + const guildId = Number.parseInt(match[1] as any) + lGuild.debug`Handle ${handle}'s Guild ID seems to be: ${guildId}` + + lGuild.debug`Making sure the Guild ID parsing didn't explode...` + if(Number.isFinite(guildId)) { + lGuild.error`Guild ID parsing for ${handle} exploded with ${guildId}, returning null.` + return null + } + + lPlayer.debug`Getting guild data for ${guildId}...` + const guild = await this.dv.stratz.doQueryGuild(guildId) + if(guild === null) { + lGuild.warn`No guild was found with ID: ${guildId}` + return null + } + + lGuild.debug`Creating ActivityPub actor for guild ${guildId}...` + const actor = new Organization({ + ...(await this.getCommonActorProperties(ctx, handle)), + + name: `[TEST] ${escapeHtml(guild.name)}`, + summary: escapeHtml(guild.description), + icon: new Image({ + url: new URL(`https://steamusercontent-a.akamaihd.net/ugc/${guild.logo}`), + mediaType: "image/jpeg" + }), + published: Temporal.Instant.fromEpochMilliseconds(guild.createdDateTime * 1000), + attachments: [ + new PropertyValue({ + name: "Tag", + value: `[${escapeHtml(guild.tag)}]`, + }), + new PropertyValue({ + name: "Message of the day", + value: escapeHtml(guild.motd), + }), + new PropertyValue({ + name: "STRATZ", + value: `https://stratz.com/guilds/${guild.id}`, + }), + ] + }) + lGuild.debug`Generated ActivityPub actor for guild ${guildId}: ${actor}` + + return actor + } +} \ No newline at end of file diff --git a/src/dv/component.ts b/src/dv/component.ts new file mode 100644 index 0000000..2b7476a --- /dev/null +++ b/src/dv/component.ts @@ -0,0 +1,15 @@ +import { getLogger } from "@logtape/logtape" +import { DotinoVeloce } from "./index.ts" + + +const l = getLogger(["dotino-veloce", "dv", "component"]) + + +export class DvComponent { + dv: DotinoVeloce + + constructor(dv: DotinoVeloce) { + l.debug`Instantiating DvComponent...` + this.dv = dv + } +} diff --git a/src/dv/follow.ts b/src/dv/follow.ts new file mode 100644 index 0000000..380b63a --- /dev/null +++ b/src/dv/follow.ts @@ -0,0 +1,114 @@ +import { getLogger } from "@logtape/logtape" +import { ContextData } from "./index.ts" +import { Accept, Context, Follow, Reject } from "@fedify/fedify" +import { DvComponent } from "./component.ts" + + +const lPlus = getLogger(["dotino-veloce", "dv", "follow", "plus"]) +const lMinus = getLogger(["dotino-veloce", "dv", "follow", "minus"]) + + +export class FollowComponent extends DvComponent { + async handlePlus(ctx: Context, follow: Follow) { + lPlus.debug`Received Follow activity: ${follow}` + + if(!follow.id) { + lPlus.warn`Missing follow ID, skipping.` + return + } + if(!follow.actorId) { + lPlus.warn`Missing actor ID, skipping.` + return + } + if(!follow.objectId) { + lPlus.warn`Missing object ID, skipping.` + return + } + + lPlus.debug`Attempting to determine who sent the follow request...` + const actor = await follow.getActor(ctx) + + if(!actor) { + lPlus.warn`Failed to determine who sent the follow request, skipping.` + return + } + + lPlus.debug`Attempting to determine who is being followed...` + const object = ctx.parseUri(follow.objectId) + + if(!object) { + lPlus.warn`Failed to determine who is being followed, skipping.` + return + } + if(object.type !== "actor") { + lPlus.warn`Attempting to follow something that is not actor, skipping.` + return + } + + lPlus.info`Confirming follow request from ${follow.actorId} to ${follow.objectId}...` + + lPlus.debug`Storing follow information in the database...` + this.dv.db.putFollow(follow.actorId.toString(), follow.objectId.toString()) + + lPlus.debug`Sending follow Accept activity...` + await ctx.sendActivity(object, "followers", new Accept({ + object: follow, + actor: follow.objectId, + to: follow.actorId, + })) + + lPlus.debug`Successfully accepted follow request!` + } + + async handleMinus(ctx: Context, follow: Follow) { + lMinus.debug`Received Undo Follow activity: ${follow}` + + if(!follow.id) { + lMinus.warn`Missing follow ID, skipping.` + return + } + if(!follow.actorId) { + lMinus.warn`Missing actor ID, skipping.` + return + } + if(!follow.objectId) { + lMinus.warn`Missing object ID, skipping.` + return + } + + lMinus.debug`Attempting to determine who sent the follow request...` + const actor = await follow.getActor(ctx) + + if(!actor) { + lMinus.warn`Failed to determine who sent the follow request, skipping.` + return + } + + lMinus.debug`Attempting to determine who is being followed...` + const object = ctx.parseUri(follow.objectId) + + if(!object) { + lMinus.warn`Failed to determine who is being followed, skipping.` + return + } + if(object.type !== "actor") { + lMinus.warn`Attempting to follow something that is not actor, skipping.` + return + } + + lMinus.info`Undoing follow request from ${follow.actorId} to ${follow.objectId}...` + + lMinus.debug`Deleting follow information from the database...` + this.dv.db.deleteFollow(follow.actorId.toString(), follow.objectId.toString()) + + // Do I really need to do this? + lMinus.debug`Sending follow Reject activity...` + await ctx.sendActivity(object, "followers", new Reject({ + object: follow, + actor: follow.objectId, + to: follow.actorId, + })) + + lMinus.debug`Successfully undone follow request!` + } +} diff --git a/src/dv/index.ts b/src/dv/index.ts index a0a851f..5cd82b8 100644 --- a/src/dv/index.ts +++ b/src/dv/index.ts @@ -1,10 +1,12 @@ -// deno-lint-ignore-file require-await -import { createFederation, Person, Application, Image, PropertyValue, Organization, Federation, Context, Actor, Follow, Endpoints, importJwk, Accept } from "@fedify/fedify" +import { createFederation, Federation, Follow, Undo } from "@fedify/fedify" import { getLogger } from "https://jsr.io/@logtape/logtape/0.6.3/logtape/logger.ts" -import { escapeHtml } from "@@x/escape" import { StratzAPI } from "../stratz/api.ts" import { Database } from "../database/index.ts" -import { handleNodeInfo } from "./nodeinfo.ts" +import { NodeInfoComponent } from "./nodeinfo.ts" +import { ActorComponent } from "./actor.ts" +import { KeyComponent } from "./key.ts" +import { FollowComponent } from "./follow.ts" +import { UndoComponent } from "./undo.ts" const l = getLogger(["dotino-veloce", "dv", "index"]) @@ -18,9 +20,21 @@ export class DotinoVeloce { stratz: StratzAPI federation: Federation + cNodeInfo: NodeInfoComponent + cActor: ActorComponent + cKey: KeyComponent + cFollow: FollowComponent + cUndo: UndoComponent + constructor(db: Database, stratz: StratzAPI) { this.db = db this.stratz = stratz + + this.cNodeInfo = new NodeInfoComponent(this) + this.cActor = new ActorComponent(this) + this.cKey = new KeyComponent(this) + this.cFollow = new FollowComponent(this) + this.cUndo = new UndoComponent(this) this.federation = createFederation({ kv: db.useAsKvStore(), @@ -28,292 +42,21 @@ export class DotinoVeloce { }) this.federation - .setActorDispatcher("/users/{identifier}", this.#actorHandler.bind(this)) - .setKeyPairsDispatcher(this.#actorKeys.bind(this)) - .mapHandle(this.#actorMapper.bind(this)) + .setActorDispatcher("/users/{identifier}", this.cActor.getActor.bind(this.cActor)) + .setKeyPairsDispatcher(this.cKey.getActorKeys.bind(this.cKey)) + .mapHandle((_, h) => h) this.federation .setInboxListeners("/inbox/{identifier}", "/inbox") // Akkoma with Authorized Fetch requires this to be set // https://p.junimo.party/#/junimo.party/s/AnFW6s3OURPMY04LKq?view=full - .setSharedKeyDispatcher(this.#instanceKeys.bind(this)) - .on(Follow, this.#followHandler.bind(this)) + .setSharedKeyDispatcher(this.cKey.getFetchKey.bind(this.cKey)) + .on(Follow, this.cFollow.handlePlus.bind(this.cFollow)) + .on(Undo, this.cUndo.handle.bind(this.cUndo)) this.federation - .setNodeInfoDispatcher("/nodeinfo/2.1", handleNodeInfo) + .setNodeInfoDispatcher("/nodeinfo/2.1", this.cNodeInfo.handle.bind(this.cNodeInfo)) } - async #commonActorProperties(ctx: Context, handle: string): Promise> { - l.debug`Generating common actor properties for ${handle}...` - const properties = { - preferredUsername: handle, - id: ctx.getActorUri(handle), - publicKeys: (await ctx.getActorKeyPairs(handle)).map(keyPair => keyPair.cryptographicKey), - assertionMethods: (await ctx.getActorKeyPairs(handle)).map(keyPair => keyPair.multikey), - // Akkoma requires inboxes to be setup to display profiles - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 - inbox: ctx.getInboxUri(handle), - endpoints: new Endpoints({ - sharedInbox: ctx.getInboxUri(), - }), - } - l.debug`Generated common actor properties for ${handle}: ${properties}` - return properties - } - - static playerMatcher = /u([0-9]+)/ - - async #playerActor(ctx: Context, handle: string): Promise { - l.debug`Checking if handle ${handle} matches regex for player accounts...` - const match = handle.match(DotinoVeloce.playerMatcher) - if(!match) { - l.debug`No match for player account regex, returning null.` - return null - } - - l.debug`Getting handle ${handle}'s SteamID...` - const steamId = Number.parseInt(match[1] as any) - l.debug`Handle ${handle}'s SteamID seems to be: ${steamId}` - - l.debug`Making sure the SteamID parsing didn't explode...` - if(!Number.isFinite(steamId)) { - l.error`SteamID parsing for ${handle} exploded with ${steamId}, returning null.` - return null - } - - l.debug`Getting player data for ${steamId}...` - const player = await this.stratz.doQueryPlayer(steamId) - if(player === null) { - l.debug`No Steam account was found with ID: ${steamId}` - return null - } - - l.debug`Generating ActivityPub actor for player ${steamId}...` - const actor = new Person({ - ...(await this.#commonActorProperties(ctx, handle)), - - name: `[TEST] ${escapeHtml(player.name)}`, - icon: new Image({ - url: new URL(player.avatar), - mediaType: "image/jpeg" - }), - attachments: [ - new PropertyValue({ - name: "Steam", - value: `https://steamcommunity.com/profiles/[U:1:${player.id}]`, - }), - new PropertyValue({ - name: "STRATZ", - value: `https://stratz.com/players/${player.id}`, - }), - new PropertyValue({ - name: "OpenDota", - value: `https://www.opendota.com/players/${player.id}`, - }), - new PropertyValue({ - name: "DOTABUFF", - value: `https://www.dotabuff.com/players/${player.id}`, - }), - ], - }) - l.debug`Generated ActivityPub actor for player ${steamId}: ${actor}` - - return actor - } - - static guildMatcher = /g([0-9]+)/ - - async #guildActor(ctx: Context, handle: string): Promise { - l.debug`Checking if handle ${handle} matches regex for guild accounts...` - const match = handle.match(DotinoVeloce.guildMatcher) - if(!match) { - l.debug`No match for guild account regex, returning null.` - return null - } - - l.debug`Getting handle ${handle}'s Guild ID...` - const guildId = Number.parseInt(match[1] as any) - l.debug`Handle ${handle}'s Guild ID seems to be: ${guildId}` - - l.debug`Making sure the Guild ID parsing didn't explode...` - if(Number.isFinite(guildId)) { - l.error`Guild ID parsing for ${handle} exploded with ${guildId}, returning null.` - return null - } - - - const guild = await this.stratz.doQueryGuild(guildId) - - if(guild === null) { - l.warn`No guild was found with ID: ${guildId}` - return null - } - - l.debug`Generating ActivityPub actor for guild ${guildId}...` - const actor = new Organization({ - ...(await this.#commonActorProperties(ctx, handle)), - - name: `[TEST] ${escapeHtml(guild.name)}`, - summary: escapeHtml(guild.description), - icon: new Image({ - url: new URL(`https://steamusercontent-a.akamaihd.net/ugc/${guild.logo}`), - mediaType: "image/jpeg" - }), - published: Temporal.Instant.fromEpochMilliseconds(guild.createdDateTime * 1000), - attachments: [ - new PropertyValue({ - name: "Tag", - value: `[${escapeHtml(guild.tag)}]`, - }), - new PropertyValue({ - name: "Message of the day", - value: escapeHtml(guild.motd), - }), - new PropertyValue({ - name: "STRATZ", - value: `https://stratz.com/guilds/${guild.id}`, - }), - ] - }) - l.debug`Generated ActivityPub actor for guild ${guildId}: ${actor}` - - return actor - } - - static serviceMatcher = /service/ - - async #serviceActor(ctx: Context, handle: string): Promise { - l.debug`Checking if handle ${handle} matches regex for the service account...` - const match = handle.match(DotinoVeloce.serviceMatcher) - if(!match) { - l.debug`No match for service account regex, returning null.` - return null - } - - l.debug`Generating ActivityPub actor for service...` - const actor = new Application({ - ...(await this.#commonActorProperties(ctx, handle)), - - name: "[TEST] Dotino Veloce", - summary: "Service account of a Dotino Veloce instance.", - attachments: [ - new PropertyValue({ - name: "Source code", - value: `https://forge.steffo.eu/steffo/dotino-veloce`, - }), - ] - }) - - l.debug`Generated ActivityPub actor for service: ${actor}` - return actor - } - - async #actorHandler(ctx: Context, handle: string): Promise { - l.info`Handling actor with handle: ${handle}` - - let actor = null - - actor ??= await this.#serviceActor(ctx, handle) - actor ??= await this.#playerActor(ctx, handle) - actor ??= await this.#guildActor(ctx, handle) - - return actor - } - - async #actorMapper(_ctx: Context, handle: string): Promise { - l.debug`Getting handle mapping for: ${handle}` - return handle - } - - async #actorKeys(_ctx: Context, handle: string): Promise { - l.debug`Getting keys for: ${handle}` - - l.debug`Requesting keys from the database...` - const { - public_rsa: publicRsaJwk, - public_ed: publicEdJwk, - private_rsa: privateRsaJwk, - private_ed: privateEdJwk, - } = await this.db.getActorService() - - l.debug`Importing keys from JWK format...` - const publicRsa = await importJwk(publicRsaJwk, "public") - const publicEd = await importJwk(publicEdJwk, "public") - const privateRsa = await importJwk(privateRsaJwk, "private") - const privateEd = await importJwk(privateEdJwk, "private") - - l.debug`Bundling keys...` - const result = [ - { - publicKey: publicRsa, - privateKey: privateRsa, - }, - { - publicKey: publicEd, - privateKey: privateEd, - } - ] - l.debug`Bundled keys are: ${result}` - - return result - } - - async #instanceKeys(_ctx: Context): Promise<{ identifier: string }> { - l.debug`Getting instance keys...` - return {"identifier": "garasauto"} - } - - async #authorizationHandler(_ctx: Context, _handle: string, _signedKey: unknown, _signedKeyOwner: unknown): Promise { - return true - } - - async #followHandler(ctx: Context, follow: Follow) { - l.info`Handling follow request: ${follow}` - - if(!follow.id) { - l.warn`Missing follow ID, skipping.` - return - } - if(!follow.actorId) { - l.warn`Missing actor ID, skipping.` - return - } - if(!follow.objectId) { - l.warn`Missing object ID, skipping.` - return - } - - l.debug`Attempting to determine what is being followed...` - const object = ctx.parseUri(follow.objectId) - l.debug`Object is: ${object}` - - if(!object) { - l.warn`Failed to determine what is being followed, skipping.` - return - } - if(object.type !== "actor") { - l.warn`Attempting to follow something that is not actor, skipping.` - return - } - - l.debug`Attempting to determine who sent the follow request...` - const actor = await follow.getActor(ctx) - l.debug`Actor is: ${actor}` - - if(!actor) { - l.warn`Failed to determine who sent the follow request, skipping.` - return - } - if(object.type !== "actor") { - l.warn`Being followed by someone who isn't an actor, skipping.` - return - } - - l.info`Accepting follow request from ${follow.actorId} to ${follow.objectId}...` - await ctx.sendActivity(object, "followers", new Accept({ - object: follow, - actor: follow.objectId, - to: follow.actorId, - })) - } -} + // REMINDER: A pattern matcher on received activities could be useful, actually. +} \ No newline at end of file diff --git a/src/dv/key.ts b/src/dv/key.ts new file mode 100644 index 0000000..ccdbeec --- /dev/null +++ b/src/dv/key.ts @@ -0,0 +1,68 @@ +import { Context, importJwk, SenderKeyPair } from "@fedify/fedify" +import { ContextData } from "./index.ts" +import { getLogger } from "@logtape/logtape" +import { DvComponent } from "./component.ts" + + +const lActor = getLogger(["dotino-veloce", "dv", "key", "actor"]) +const lFetch = getLogger(["dotino-veloce", "dv", "key", "fetch"]) + + +export class KeyComponent extends DvComponent { + async getActorKeys(_ctx: Context, handle: string): Promise { + lActor.info`Getting actor keys for: ${handle}` + + lActor.debug`Requesting keys from the database...` + const { + public_rsa: publicRsaJwk, + public_ed: publicEdJwk, + private_rsa: privateRsaJwk, + private_ed: privateEdJwk, + } = await this.dv.db.getActorApp() + + lActor.debug`Importing keys from JWK format...` + const publicRsa = await importJwk(publicRsaJwk, "public") + const publicEd = await importJwk(publicEdJwk, "public") + const privateRsa = await importJwk(privateRsaJwk, "private") + const privateEd = await importJwk(privateEdJwk, "private") + + lActor.debug`Bundling keys...` + const result = [ + { + publicKey: publicRsa, + privateKey: privateRsa, + }, + { + publicKey: publicEd, + privateKey: privateEd, + } + ] + lActor.debug`Bundled keys are: ${result}` + + return result + } + + async getFetchKey(ctx: Context): Promise { + lFetch.info`Getting fetch key...` + + lFetch.debug`Requesting key from the database...` + const { + private_rsa: privateKeyJwk, + } = await this.dv.db.getActorApp() + + lFetch.debug`Importing key from JWK format...` + const privateKey = await importJwk(privateKeyJwk, "private") + + lFetch.debug`Getting app actor URI...` + const keyId = ctx.getActorUri("app") + + lFetch.debug`Appending #main-key to the app actor URI...` + keyId.hash = "main-key" + + lFetch.debug`Bundling key...` + const result = {privateKey, keyId} + lFetch.debug`Bundled key is: ${result}` + + return result + } +} \ No newline at end of file diff --git a/src/dv/nodeinfo.ts b/src/dv/nodeinfo.ts index f29e0af..c6945aa 100644 --- a/src/dv/nodeinfo.ts +++ b/src/dv/nodeinfo.ts @@ -1,33 +1,35 @@ import { Context, NodeInfo } from "@fedify/fedify" import { ContextData } from "./index.ts" import { getLogger } from "@logtape/logtape" +import { DvComponent } from "./component.ts" const l = getLogger(["dotino-veloce", "dv", "nodeinfo"]) -export function handleNodeInfo(_ctx: Context): NodeInfo { - l.info`Creating nodeinfo for...` - - return { - software: { - name: "dotino-veloce", - version: { - major: 0, - minor: 1, - patch: 0, +export class NodeInfoComponent extends DvComponent { + handle(_ctx: Context): NodeInfo { + l.debug`Creating nodeinfo...` + return { + software: { + name: "dotino-veloce", + version: { + major: 0, + minor: 1, + patch: 0, + }, + repository: new URL("https://forge.steffo.eu/steffo/dotino-veloce"), }, - repository: new URL("https://forge.steffo.eu/steffo/dotino-veloce"), - }, - protocols: ["activitypub"], - usage: { - users: { - total: 0, - activeHalfyear: 0, - activeMonth: 0, - }, - localPosts: 0, - localComments: 0, + protocols: ["activitypub"], + usage: { + users: { + total: 0, + activeHalfyear: 0, + activeMonth: 0, + }, + localPosts: 0, + localComments: 0, + } } } -} \ No newline at end of file +} diff --git a/src/dv/undo.ts b/src/dv/undo.ts new file mode 100644 index 0000000..3baa390 --- /dev/null +++ b/src/dv/undo.ts @@ -0,0 +1,14 @@ +import { getLogger } from "@logtape/logtape" +import { ContextData } from "./index.ts" +import { Context, Undo } from "@fedify/fedify" +import { DvComponent } from "./component.ts" + + +const l = getLogger(["dotino-veloce", "dv", "undo"]) + + +export class UndoComponent extends DvComponent { + async handle(_ctx: Context, undo: Undo) { + l.error`Not implemented: ${undo}` + } +}