Commit 19d258dd authored by Grant's avatar Grant
Browse files

initial handoff implementation

parent 56cf0bcf
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -21,7 +21,8 @@
    "express-session": "^1.18.0",
    "ioredis": "^5.6.1",
    "oidc-provider": "^8.8.1",
    "openid-client": "^5.6.5"
    "openid-client": "^5.6.5",
    "string-strip-html": "^13.4.12"
  },
  "devDependencies": {
    "@types/cookie-parser": "^1.4.7",
+10 −0
Original line number Diff line number Diff line
-- CreateTable
CREATE TABLE "HandoffSession" (
    "id" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "expiresAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "HandoffSession_pkey" PRIMARY KEY ("id")
);
+9 −0
Original line number Diff line number Diff line
@@ -51,3 +51,12 @@ model FediverseUser {
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt
}

model HandoffSession {
  id     String @id @default(uuid())
  userId String // eg. https://grants.cafe/users/grant

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt
  expiresAt DateTime
}
+82 −0
Original line number Diff line number Diff line
import { HandoffSession as DBHandoffSession } from "@prisma/client";
import { prisma } from "../lib/prisma.js";

export class HandoffSession {
  static async create(userId: string): Promise<HandoffSession> {
    // 15 minutes
    const expiresAt = new Date(Date.now() + 1000 * 60 * 15);

    const session = await prisma.handoffSession.create({
      data: {
        userId,
        expiresAt,
      },
    });

    return new HandoffSession(session);
  }
  static async getExpired() {
    const sessions = await prisma.handoffSession.findMany({
      where: {
        expiresAt: {
          lte: new Date(),
        },
      },
    });
    return sessions.map((d) => new HandoffSession(d));
  }
  static async get(id: string) {
    const session = await prisma.handoffSession.findFirst({
      where: {
        id,
        expiresAt: { gt: new Date() },
      },
    });

    if (!session) return null;

    return new HandoffSession(session);
  }

  private _id: string;
  private _userId: string;
  private _createdAt: Date;
  private _updatedAt: Date;
  private _expiresAt: Date;

  private constructor(session: DBHandoffSession) {
    this._id = session.id;
    this._userId = session.userId;
    this._createdAt = session.createdAt;
    this._updatedAt = session.updatedAt;
    this._expiresAt = session.expiresAt;
  }

  get id() {
    return this._id;
  }

  get userId() {
    return this._userId;
  }

  get createdAt() {
    return this._createdAt;
  }

  get updatedAt() {
    return this._updatedAt;
  }

  get expiresAt() {
    return this._expiresAt;
  }

  getURL() {
    return new URL(`/handoff/${this._id}`, process.env.OIDC_ISSUER);
  }

  destroy() {
    return prisma.handoffSession.delete({ where: { id: this._id } });
  }
}
+82 −0
Original line number Diff line number Diff line
import { Actor, ChatMessage, Create, Mention, Note } from "@fedify/fedify";
import { HandoffSession } from "../controllers/HandoffSession.js";
import { APub } from "../lib/apub/utils.js";
import { USER_IDENTIFIER } from "../lib/apub/federation.js";
import { Temporal } from "@js-temporal/polyfill";
import { stripHtml } from "string-strip-html";

type Message = Note | ChatMessage;

/**
 * Handle automated exchange of tokens from third party apps
 */
export class HandoffActivityPub {
  async handle(actor: Actor, object: Message, create: Create) {
    if (!object.content && !object.contents[0]) return;
    if (!actor.id) return;

    const check = this.isObjectValid(object);
    if (!check.valid) return;

    const handoff = await HandoffSession.create(actor.id.toString());
    const apub = APub.get();
    const sender = apub.ctx.getActorUri(USER_IDENTIFIER);

    if (object instanceof Note) {
      await apub.sendNote(
        "handoff-" + handoff.id,
        actor,
        new Note({
          id: apub.ctx.getObjectUri(Note, { id: "handoff-" + handoff.id }),
          attribution: sender,
          to: actor.id,
          published: Temporal.Instant.from(handoff.createdAt.toISOString()),
          replyTarget: object,
          content: handoff.getURL().toString(),
          tags: [
            new Mention({
              href: actor.id,
              name: actor.id!.toString(),
            }),
          ],
        })
      );
    }

    if (object instanceof ChatMessage) {
      await apub.sendChatMessage(
        "handoff-" + handoff.id,
        actor,
        new ChatMessage({
          id: apub.ctx.getObjectUri(ChatMessage, {
            id: "handoff-" + handoff.id,
          }),
          attribution: sender,
          to: actor.id,
          published: Temporal.Instant.from(handoff.createdAt.toISOString()),
          replyTarget: object,
          content: handoff.getURL().toString(),
        })
      );
    }
  }

  isObjectValid(
    object: Message
  ): { valid: false } | { valid: true; software: string } {
    const TOKEN = process.env.AUTOMATED_EXCHANGE_TOKEN;

    if (!TOKEN) return { valid: false };

    const content = stripHtml(
      object.content?.toString() || object.contents[0]?.toString()
    ).result;

    if (content.indexOf(TOKEN) === -1) return { valid: false };

    return {
      valid: true,
      software: "unknown",
    };
  }
}
Loading