this repo has no description
1import logger from "./logger";
2
3import { and, eq } from "drizzle-orm";
4import { nanoid } from "nanoid/non-secure";
5
6import { read } from "bun:ffi";
7import config from "../config";
8import db from "../db";
9import { schema } from "../db";
10import { generateKeyPair, signData } from "./crypto";
11import type {
12 AcceptActivity,
13 ActivityPubActivity,
14 CreateNoteActivity,
15 FederatedActor,
16 FollowActivity,
17} from "./types";
18
19export function getBaseUrl(req?: Request): string {
20 if (req) {
21 const host = req.headers.get("host") || "localhost";
22 const proto = req.headers.get("x-forwarded-proto") || "http";
23 return `${proto}://${host}`;
24 }
25
26 return `${config.server.secure ? "https" : "http"}://${config.server.hostName || "localhost"}`;
27}
28
29export async function ensureActorExists(
30 userId: number,
31 baseUrl: string,
32): Promise<string> {
33 const existingActor = db
34 .select()
35 .from(schema.federatedActors)
36 .where(
37 and(
38 eq(schema.federatedActors.userId, userId),
39 eq(schema.federatedActors.actorType, "user"),
40 ),
41 )
42 .get();
43
44 if (existingActor) return existingActor.id;
45 const user = db
46 .select()
47 .from(schema.users)
48 .where(eq(schema.users.id, userId))
49 .get();
50
51 if (!user) throw new Error("User not found");
52 const keys = await generateKeyPair();
53 const actorUrl = `${baseUrl}/ap/users/${user.username}`;
54 try {
55 const actor = await db
56 .insert(schema.federatedActors)
57 .values({
58 id: nanoid(),
59 userId,
60 communityId: null,
61 actorType: "user",
62 actorUrl,
63 inbox: `${actorUrl}/inbox`,
64 outbox: `${actorUrl}/outbox`,
65 followers: `${actorUrl}/followers`,
66 following: `${actorUrl}/following`,
67 publicKey: keys.publicKey,
68 privateKey: keys.privateKey,
69 sharedInbox: `${baseUrl}/ap/shared/inbox`,
70 })
71 .returning();
72
73 return actor[0].id;
74 } catch (error) {
75 console.error(error);
76 throw new Error("Failed to create actor");
77 }
78}
79
80export async function ensureCommunityActorExists(
81 communityId: string,
82 baseUrl: string,
83): Promise<string> {
84 const existingActor = db
85 .select()
86 .from(schema.federatedActors)
87 .where(
88 and(
89 eq(schema.federatedActors.communityId, communityId),
90 eq(schema.federatedActors.actorType, "community"),
91 ),
92 )
93 .get();
94
95 if (existingActor) return existingActor.id;
96
97 const community = db
98 .select()
99 .from(schema.communities)
100 .where(eq(schema.communities.id, communityId))
101 .get();
102
103 if (!community) throw new Error("community not found");
104
105 const keys = await generateKeyPair();
106 const actorUrl = `${baseUrl}/ap/communities/${communityId}`;
107
108 const actor = await db
109 .insert(schema.federatedActors)
110 .values({
111 id: nanoid(),
112 userId: null,
113 communityId,
114 actorType: "community",
115 actorUrl,
116 inbox: `${actorUrl}/inbox`,
117 outbox: `${actorUrl}/outbox`,
118 followers: `${actorUrl}/followers`,
119 following: `${actorUrl}/following`,
120 publicKey: keys.publicKey,
121 privateKey: keys.privateKey,
122 sharedInbox: `${baseUrl}/ap/shared/inbox`,
123 })
124 .returning();
125
126 return actor[0].id;
127}
128
129export function createMessageActivity(
130 actorUrl: string,
131 messageId: string,
132 content: string,
133 channelUrl: string,
134): CreateNoteActivity {
135 return {
136 "@context": "https://www.w3.org/ns/activitystreams",
137 id: `${actorUrl}/activities/${nanoid()}`,
138 type: "Create",
139 actor: actorUrl,
140 published: new Date().toISOString(),
141 to: [channelUrl],
142 object: {
143 id: `${actorUrl}/notes/${messageId}`,
144 type: "Note",
145 attributedTo: actorUrl,
146 content,
147 published: new Date().toISOString(),
148 to: [channelUrl],
149 },
150 };
151}
152
153export function createFollowActivity(
154 actorUrl: string,
155 targetActorUrl: string,
156): FollowActivity {
157 return {
158 "@context": "https://www.w3.org/ns/activitystreams",
159 id: `${actorUrl}/activities/${nanoid()}`,
160 type: "Follow",
161 actor: actorUrl,
162 object: targetActorUrl,
163 published: new Date().toISOString(),
164 };
165}
166
167export function createAcceptActivity(
168 actorUrl: string,
169 followActivity: FollowActivity,
170): AcceptActivity {
171 return {
172 "@context": "https://www.w3.org/ns/activitystreams",
173 id: `${actorUrl}/activities/${nanoid()}`,
174 type: "Accept",
175 actor: actorUrl,
176 object: followActivity,
177 published: new Date().toISOString(),
178 };
179}
180
181export async function deliverActivity(
182 activity: ActivityPubActivity,
183 targetInbox: string,
184 privateKey: string,
185 keyId: string,
186): Promise<boolean> {
187 try {
188 const activityJson = JSON.stringify(activity);
189
190 const date = new Date().toUTCString();
191 const parsedUrl = new URL(targetInbox);
192 const path = parsedUrl.pathname;
193
194 const stringToSign = `(request-target): post ${path}\nhost: ${parsedUrl.host}\ndate: ${date}\ndigest: SHA-256=${Buffer.from(activityJson).toString("base64")}`;
195 const signature = await signData(stringToSign, privateKey);
196 const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
197
198 const response = await fetch(targetInbox, {
199 method: "POST",
200 headers: {
201 "Content-Type": "application/activity+json",
202 Host: parsedUrl.host,
203 Date: date,
204 Digest: `SHA-256=${Buffer.from(activityJson).toString("base64")}`,
205 Signature: signatureHeader,
206 },
207 body: activityJson,
208 });
209
210 if (!response.ok) {
211 logger.error(
212 `failed to deliver activity to ${targetInbox}: ${response.status} ${response.statusText}`,
213 );
214 return false;
215 }
216
217 return true;
218 } catch (error) {
219 logger.error(`error delivering activity to ${targetInbox}:`, error);
220 return false;
221 }
222}